diff --git a/.claude/agent-memory/code-reviewer/MEMORY.md b/.claude/agent-memory/code-reviewer/MEMORY.md index 1c627f918911..e01f7fc122fe 100644 --- a/.claude/agent-memory/code-reviewer/MEMORY.md +++ b/.claude/agent-memory/code-reviewer/MEMORY.md @@ -69,6 +69,8 @@ ## See Also -- `equil-migration.md` — detailed Equil Compose migration review (2026-03-09) +- `carelevo-ble-transport-migration.md` — Carelevo BLE stack unification onto shared `BleTransport` + (Dana/Equil/Medtrum abstraction), Phase 1 adapter (`BleTransportGattConnection`) review (2026-07-08). + Confirms double-`@Singleton` DI pattern is intentional fleet convention (see Equil precedent), not a bug. - Earlier migration reviews (NSClient, Tidepool, Wear, SMS, Preferences, EOPatch2): see conversation history from 2026-03-01 and 2026-03-02. diff --git a/.claude/agent-memory/code-reviewer/carelevo-ble-transport-migration.md b/.claude/agent-memory/code-reviewer/carelevo-ble-transport-migration.md new file mode 100644 index 000000000000..8d43b8e25874 --- /dev/null +++ b/.claude/agent-memory/code-reviewer/carelevo-ble-transport-migration.md @@ -0,0 +1,81 @@ +# Carelevo BLE Transport Migration (Phase 1) — review notes (2026-07-08) + +## Architecture +- Shared fleet abstraction: `core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/ble/BleTransport.kt` + (`BleTransport`/`BleAdapter`/`BleScanner`/`BleGatt`/`BleTransportListener`). Used by Dana/Equil/Medtrum/Carelevo. + `BleGatt.writeCharacteristic(data)` and `.enableNotifications()` have NO uuid param — the transport + hardcodes a single write char + single notify char internally (one-service/one-write/one-notify patch + pump shape). `BleTransportListener.onCharacteristicWritten()` carries no status (unlike + `BluetoothGattCallback.onCharacteristicWrite`'s status int) — silently-dropped writes degrade to the + caller's own `withTimeout`, they don't fast-fail. Single listener via `setListener()` (last write wins, + no ownership enforcement). +- `pump/carelevo/.../ble/gatt/BleTransportGattConnection.kt` is a NEW adapter bridging `BleTransport` to + carelevo's own pre-existing `GattConnection` contract, so `BleClientImpl` (coroutine correlation layer) + runs unchanged over either the old `AndroidGattConnection` (direct `BluetoothGatt` wrapper) or the new + shared-transport adapter. Mirrors `AndroidGattConnection`'s internals almost line for line (gattMutex + + 3x `@Volatile CompletableDeferred` for write/discovery/descriptor acks). +- `pump/equil/.../ble/EquilBleTransportImpl.kt` is the template `CarelevoBleTransportImpl.kt` was cloned + from — near-identical structure (inner Adapter/Scanner/Gatt classes, `@Synchronized` gattCallback for + conn-state/svc-discovery/descriptor-write, unsynchronized for char write/read/changed). + +## Confirmed non-issues (checked against Equil precedent, don't re-flag) +- **Double `@Singleton`** (class-level `@Singleton class XxxBleTransportImpl @Inject constructor` PLUS + `@Provides @Singleton fun provideXxxBleTransport(impl: XxxBleTransportImpl): XxxBleTransport = impl`) + is the established fleet pattern, not a bug. See `app/src/withPumps/kotlin/app/aaps/di/EquilModules.kt` + — this indirection exists so the app module can swap in an emulator transport + (`EquilEmulatorBleTransport`) at runtime while the impl module's own `@Module class` (non-abstract, so + no `@Binds`) still exposes the concrete singleton. Carelevo's `CarelevoBleModule.kt` binds it locally + instead (no emulator swap yet — KDoc says "a future emulator impl can be swapped in here"). Functionally + identical either way (Dagger caches per scoped binding, exactly one instance). +- Synchronous listener callback firing INSIDE `gatt.writeCharacteristic()` on the not-connected path + (`listener?.onConnectionStateChanged(false)`) correctly aborts the just-registered `writeAck` deferred + with no deadlock risk — `completeExceptionally` never tries to reacquire `gattMutex`, and it all runs on + the same coroutine/call-stack that already holds the mutex from `writeCharacteristic`'s `withLock` block. + Verified via `BleTransportGattConnectionTest` `writeCharacteristic on a not-connected transport fast- + fails...` test — legit coverage, not trivially green. +- `CarelevoBleTransportImpl.onServicesDiscovered` tightening the success condition to + `service != null && notifyChara != null && writeChara != null` (vs Equil's bare `service != null`) is a + **safety improvement**, not a regression — Equil's version can report false "success" if the service is + found but a characteristic UUID lookup inside it fails (firmware/UUID mismatch), only surfacing the real + failure later on the first write/enableNotifications call. +- UUID role wiring verified correct: `BleEnvConfig`/`CarelevoConfig.kt` — service `e1b40001`, TX(notify) + `e1b40003` → `notifyChara`, RX(write) `e1b40002` → `writeChara`, CCCD `2902`. Matches Equil's analogous + NRF_UART_NOTIFY/NRF_UART_WRITE mapping. + +## Real findings (see full review text in conversation 2026-07-08 for details — recap here) +- **High**: `BleTransportGattConnection` has no self-tracked "closed" flag. `close()` (lines ~122-130) + nulls the transport's listener slot (`transport.setListener(null)`) but a *subsequent* call to + `writeCharacteristic`/`discoverServices`/`enableNotifications` on the same (now-closed) adapter + re-acquires `gattMutex` uncontended, registers a fresh deferred, and calls into the transport — whose + not-connected fast-fail path (`listener?.onConnectionStateChanged(false)`) is now a silent no-op because + the listener is null. The deferred is never completed → hangs until the caller's own `withTimeout` + instead of failing fast. `AndroidGattConnection` avoids this by self-guarding with + `val g = gatt ?: throw GattWriteException(...)` at the top of every suspend op (checks its own nulled + `gatt` field, doesn't depend on a callback round-trip). Not yet reachable in production (adapter isn't + wired into any real call site yet — grep confirms only the test file constructs it), but must be fixed + before Phase 2 wiring. +- **Medium-High (latent/Phase-2)**: Listener-slot ownership has zero enforcement. `CarelevoBleTransportImpl` + is `@Singleton`; `BleTransportGattConnection` is meant to be "one adapter instance owns the transport's + single listener slot for one connection lifecycle" (its own KDoc) but nothing asserts this. If a future + reconnect path ever constructs a new adapter without `close()`-ing the previous one first, the new + adapter's `init { transport.setListener(this) }` silently steals the slot; the old adapter's in-flight + deferred(s) hang until timeout with no error surfaced. +- **Medium**: `writeCharacteristic(uuid, payload, withResponse)`'s `uuid` and `withResponse` params, and + `enableNotifications(uuid)`'s `uuid` param, are silently ignored by `BleTransportGattConnection` (the + underlying `BleGatt` interface has no per-uuid/write-type parameters — single hardcoded write/notify + char). No `require()` guard, no doc comment (contrast with the write-ack-status KDoc it does have). + A caller passing the wrong UUID (e.g. copy-paste bug) would silently "succeed" against the transport's + one real characteristic instead of erroring. +- **Medium (pre-existing, not a regression)**: `writeAck`/`discoveryAck`/`descriptorAck` are bare mutable + fields, not per-op correlation tokens. A late/stale native ack for an aborted operation could in theory + complete a *different*, newer operation's deferred after `gattMutex` released and a new op started. This + risk is identical in `AndroidGattConnection` (same design) — not introduced by the new adapter, just + worth a shared follow-up someday. +- **Test gaps**: no test for close()-then-reuse (would have caught the High finding above); no test for + double-close() idempotency (interface explicitly promises it); no test characterizing two adapters over + one transport clobbering the listener slot. Existing tests (register-before-write, synchronous not- + connected fast-fail, disconnect aborting in-flight write, full BleClient-over-adapter correlation + end-to-end via nested `scope.launch` + `runTest` virtual time) are legitimate, not trivially green. + +## See also +- [MEMORY.md](MEMORY.md) main index diff --git a/.idea/dictionaries/project_dictionary.xml b/.idea/dictionaries/project_dictionary.xml index 5ab9ab769492..66d04ee06ef4 100644 --- a/.idea/dictionaries/project_dictionary.xml +++ b/.idea/dictionaries/project_dictionary.xml @@ -34,6 +34,7 @@ carbratio carbs carbsreq + carelevo careportal cctl cellnovo diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8325c3941f62..e7fdb42b2df8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -234,6 +234,7 @@ dependencies { // - :pump:combov2:comboctl is a support lib pulled in transitively by :pump:combov2 // buildFile.exists() skips the phantom :pump:omnipod container Gradle auto-creates from the // nested :pump:omnipod:* includes (it has no build script / no consumable variant). + // :pump:carelevo is included automatically via this filter (present in settings.gradle). val pumpExclusions = setOf(":pump:virtual", ":pump:combov2:comboctl") rootProject.subprojects .filter { it.path.startsWith(":pump:") && it.path !in pumpExclusions && it.buildFile.exists() } @@ -301,4 +302,3 @@ if (!gitAvailable()) { if (isMaster() && !allCommitted()) { throw GradleException("There are uncommitted changes. Clone sources again as described in wiki and do not allow gradle update") } - diff --git a/app/src/withPumps/kotlin/app/aaps/di/CarelevoModules.kt b/app/src/withPumps/kotlin/app/aaps/di/CarelevoModules.kt new file mode 100644 index 000000000000..33a470ece046 --- /dev/null +++ b/app/src/withPumps/kotlin/app/aaps/di/CarelevoModules.kt @@ -0,0 +1,41 @@ +package app.aaps.di + +import app.aaps.core.interfaces.configuration.Config +import app.aaps.core.interfaces.configuration.ExternalOptions +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.pump.carelevo.ble.CarelevoBleTransport +import app.aaps.pump.carelevo.ble.CarelevoBleTransportImpl +import app.aaps.pump.carelevo.emulator.CarelevoEmulatorBleTransport +import app.aaps.pump.carelevo.emulator.CarelevoPumpEmulator +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class CarelevoModules { + + /** + * Selects the emulated patch over the real Bluetooth stack when the `emulate_carelevo` marker + * file is present, so the driver can be exercised end-to-end without patch hardware. + */ + @Provides + @Singleton + fun provideCarelevoBleTransport( + config: Config, + carelevoBleTransportImpl: CarelevoBleTransportImpl, + aapsLogger: AAPSLogger + ): CarelevoBleTransport = + if (config.isEnabled(ExternalOptions.EMULATE_CARELEVO)) { + aapsLogger.debug(LTag.PUMPEMULATOR, "CareLevo emulator active — real Bluetooth is not used") + CarelevoEmulatorBleTransport( + emulator = CarelevoPumpEmulator(aapsLogger = aapsLogger), + aapsLogger = aapsLogger + ) + } else { + carelevoBleTransportImpl + } +} diff --git a/app/src/withPumps/kotlin/app/aaps/di/PumpDriversModule.kt b/app/src/withPumps/kotlin/app/aaps/di/PumpDriversModule.kt index b73ec1e8f5c8..61f8f46ed1a8 100644 --- a/app/src/withPumps/kotlin/app/aaps/di/PumpDriversModule.kt +++ b/app/src/withPumps/kotlin/app/aaps/di/PumpDriversModule.kt @@ -1,5 +1,6 @@ package app.aaps.di +import app.aaps.pump.carelevo.di.CarelevoModule import app.aaps.pump.common.di.PumpCommonModule import app.aaps.pump.common.di.RileyLinkModule import app.aaps.pump.diaconn.di.DiaconnG8Module @@ -30,6 +31,8 @@ import info.nightscout.pump.combov2.di.ComboV2Module MedtrumModule::class, EquilModule::class, EquilModules::class, + CarelevoModule::class, + CarelevoModules::class ] ) @InstallIn(SingletonComponent::class) diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/ManufacturerType.kt b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/ManufacturerType.kt index 51daa400c22b..dee7202f0ecc 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/ManufacturerType.kt +++ b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/ManufacturerType.kt @@ -13,6 +13,7 @@ enum class ManufacturerType(val description: String) { Ypsomed("Ypsomed"), G2e("G2e"), Eoflow("Eoflow"), + CareMedi("CareMedi"), Equil("Equil"); } \ No newline at end of file diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpCapability.kt b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpCapability.kt index 36dc916fa9a6..594fcebdb045 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpCapability.kt +++ b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpCapability.kt @@ -20,7 +20,7 @@ enum class PumpCapability { DiaconnCapabilities(arrayOf(Capability.Bolus, Capability.ExtendedBolus, Capability.TempBasal, Capability.BasalProfileSet, Capability.Refill, Capability.ReplaceBattery, Capability.TDD, Capability.ManualTDDLoad)), // EopatchCapabilities(arrayOf(Capability.Bolus, Capability.ExtendedBolus, Capability.TempBasal, Capability.BasalProfileSet, Capability.BasalRate30min)), MedtrumCapabilities(arrayOf(Capability.Bolus, Capability.TempBasal, Capability.BasalProfileSet, Capability.BasalRate30min, Capability.TDD)), // Technically the pump supports ExtendedBolus, but not implemented (yet) - ; + CarelevoCapabilities(arrayOf(Capability.Bolus, Capability.ExtendedBolus, Capability.TempBasal, Capability.BasalProfileSet, Capability.BasalRate30min)); var children: ArrayList = ArrayList() diff --git a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpType.kt b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpType.kt index 81d1d68283c2..9ff2c49e8d70 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpType.kt +++ b/core/data/src/main/kotlin/app/aaps/core/data/pump/defs/PumpType.kt @@ -457,6 +457,26 @@ enum class PumpType( model = "untested", parent = MEDTRUM_NANO ), + + CAREMEDI_CARELEVO( + description = "Carelevo", + manufacturer = ManufacturerType.CareMedi, + model = "Carelevo", + bolusSize = 0.05, + specialBolusSize = null, + extendedBolusSettings = DoseSettings(0.05, 30, 8 * 60, 0.05, 25.0), + pumpTempBasalType = PumpTempBasalType.Absolute, + tbrSettings = DoseSettings(0.05, 30, 12 * 60, 0.0, 15.0), + specialBasalDurations = arrayOf(Capability.BasalRate_Duration30minAllowed), + baseBasalMinValue = 0.05, + baseBasalMaxValue = 15.0, + baseBasalStep = 0.05, + baseBasalSpecialSteps = null, + pumpCapability = PumpCapability.CarelevoCapabilities, + isPatchPump = true, + maxReservoirReading = 300, + source = Source.Carelevo + ), EQUIL( description = "Equil", manufacturer = ManufacturerType.Equil, @@ -511,7 +531,8 @@ enum class PumpType( MDI, VirtualPump, Unknown, - EQuil + EQuil, + Carelevo } companion object { diff --git a/core/data/src/main/kotlin/app/aaps/core/data/ue/Sources.kt b/core/data/src/main/kotlin/app/aaps/core/data/ue/Sources.kt index e0e79baf5805..398b54c5cc1c 100644 --- a/core/data/src/main/kotlin/app/aaps/core/data/ue/Sources.kt +++ b/core/data/src/main/kotlin/app/aaps/core/data/ue/Sources.kt @@ -83,6 +83,8 @@ enum class Sources { Garmin, Scene, //From Scene activation Database, // for PersistenceLayer + + Carelevo, Unknown //if necessary ; } \ No newline at end of file diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/configuration/Config.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/configuration/Config.kt index 78f4c3b10045..8bf82189a43c 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/configuration/Config.kt +++ b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/configuration/Config.kt @@ -35,6 +35,7 @@ enum class ExternalOptions(val filename: String) { EMULATE_DANA_R("emulate_dana_r"), EMULATE_DANA_R_KOREAN("emulate_dana_r_korean"), EMULATE_DANA_R_V2("emulate_dana_r_v2"), + EMULATE_CARELEVO("emulate_carelevo"), ENABLE_OMNIPOD_DRIFT_COMPENSATION("omnipod_drift_compensation"), } diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationId.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationId.kt index ff68f58543c3..797fb19b6c56 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationId.kt +++ b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/notifications/NotificationId.kt @@ -117,6 +117,9 @@ enum class NotificationId( // Pump — Dana emulator PUMP_EMULATOR_DISPLAY(INFO, PUMP), + // Pump — Carelevo + CARELEVO_PATCH_ALERT(URGENT, PUMP, allowMultiple = true), + // CGM BG_READINGS_MISSED(URGENT, CGM), SENSOR_CHANGE_DETECTED(NORMAL, CGM), diff --git a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/ble/BleTransport.kt b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/ble/BleTransport.kt index cf3aaec4ac8d..6415ceeadeab 100644 --- a/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/ble/BleTransport.kt +++ b/core/interfaces/src/main/kotlin/app/aaps/core/interfaces/pump/ble/BleTransport.kt @@ -41,7 +41,8 @@ interface BleAdapter { data class ScannedDevice( val name: String, val address: String, - val scanRecordBytes: ByteArray? = null + val scanRecordBytes: ByteArray? = null, + val rssi: Int = Int.MIN_VALUE ) interface BleScanner { diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginCarelevo.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginCarelevo.kt new file mode 100644 index 000000000000..ea066e5108f9 --- /dev/null +++ b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/icons/IcPluginCarelevo.kt @@ -0,0 +1,143 @@ +package app.aaps.core.ui.compose.icons + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Icon for CareLevo Pump Plugin. + * + * Single-color design using NonZero winding to create holes: + * CW outer body → filled border frame + * CCW inner panel → transparent window + * CCW connector dots (in border) → transparent holes + * CW lens circle → ring fill + * CCW lens center → transparent center of ring + * CW text bars → filled bars inside window + * + * Works correctly with any tint color (no multi-color tricks). + */ +val IcPluginCarelevo: ImageVector by lazy { + ImageVector.Builder( + name = "IcPluginCarelevo", + defaultWidth = 48.dp, + defaultHeight = 48.dp, + viewportWidth = 24f, + viewportHeight = 24f + ).apply { + + // ── Combined body + all winding-based cutouts/fills ─────────────────── + path( + fill = SolidColor(Color.Black), + fillAlpha = 1.0f, + stroke = null, + strokeAlpha = 1.0f, + strokeLineWidth = 1.0f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 1.0f, + pathFillType = PathFillType.NonZero + ) { + // ── 1. Outer device body (CW) ─ winding = +1 ────────────────────── + moveTo(5.9f, 4.5f) + lineTo(18.1f, 4.5f) + curveTo(20.5f, 4.5f, 22.5f, 6.5f, 22.5f, 8.9f) + lineTo(22.5f, 15.1f) + curveTo(22.5f, 17.5f, 20.5f, 19.5f, 18.1f, 19.5f) + lineTo(5.9f, 19.5f) + curveTo(3.5f, 19.5f, 1.5f, 17.5f, 1.5f, 15.1f) + lineTo(1.5f, 8.9f) + curveTo(1.5f, 6.5f, 3.5f, 4.5f, 5.9f, 4.5f) + close() + + // ── 2. Inner panel (CCW) ─ cancels to 0 → transparent window ────── + // Start TL, go DOWN first (CCW in screen-Y-down coords) + moveTo(5.8f, 6.5f) + curveTo(4.58f, 6.5f, 3.6f, 7.48f, 3.6f, 8.7f) + lineTo(3.6f, 15.3f) + curveTo(3.6f, 16.52f, 4.58f, 17.5f, 5.8f, 17.5f) + lineTo(18.2f, 17.5f) + curveTo(19.42f, 17.5f, 20.4f, 16.52f, 20.4f, 15.3f) + lineTo(20.4f, 8.7f) + curveTo(20.4f, 7.48f, 19.42f, 6.5f, 18.2f, 6.5f) + close() + + // ── 3. Connector dot holes (CCW, r=0.4) ─ transparent holes ─────── + // isPositiveArc=false → CCW arc → creates hole + // Top connector (center 6.9, 5.8) + moveTo(7.3f, 5.8f) + arcToRelative(0.4f, 0.4f, 0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = -0.8f, dy1 = 0f) + arcToRelative(0.4f, 0.4f, 0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = 0.8f, dy1 = 0f) + // Bottom connector (center 6.9, 18.3) + moveTo(7.3f, 18.3f) + arcToRelative(0.4f, 0.4f, 0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = -0.8f, dy1 = 0f) + arcToRelative(0.4f, 0.4f, 0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = 0.8f, dy1 = 0f) + // Right connector (center 21.5, 12.0) + moveTo(21.9f, 12.0f) + arcToRelative(0.4f, 0.4f, 0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = -0.8f, dy1 = 0f) + arcToRelative(0.4f, 0.4f, 0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = 0.8f, dy1 = 0f) + + // ── 4. CARELEVO text bars (CW rects) ─ +1 → fills inside window ─── + // Go RIGHT first from TL (CW in screen-Y-down coords) + moveTo(13.2f, 10.7f) + lineTo(19.5f, 10.7f) + lineTo(19.5f, 11.2f) + lineTo(13.2f, 11.2f) + close() + moveTo(13.2f, 11.75f) + lineTo(18.3f, 11.75f) + lineTo(18.3f, 12.25f) + lineTo(13.2f, 12.25f) + close() + moveTo(13.2f, 12.8f) + lineTo(19.5f, 12.8f) + lineTo(19.5f, 13.3f) + lineTo(13.2f, 13.3f) + close() + } + + // ── Lens arc – 300° open arc, gap on right (C보다 조금 더 길게) ────────── + // Center (10.1, 12.0), r = 1.75 + // Start: 30° above right → (11.62, 11.13) + // End : 30° below right → (11.62, 12.88) + // Long CCW arc (300°) going up → left → down + path( + fill = SolidColor(Color.Transparent), + fillAlpha = 0f, + stroke = SolidColor(Color.Black), + strokeAlpha = 1.0f, + strokeLineWidth = 0.85f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + strokeLineMiter = 1.0f + ) { + moveTo(11.62f, 11.13f) + arcToRelative(1.75f, 1.75f, 0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = 0f, dy1 = 1.75f) + } + + }.build() +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +private fun IcPluginCarelevoPreview() { + Icon( + imageVector = IcPluginCarelevo, + contentDescription = null, + modifier = Modifier + .padding(8.dp) + .size(48.dp), + tint = Color.Unspecified + ) +} diff --git a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/WizardScreen.kt b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/WizardScreen.kt index 4a1c5a55c7c2..aeba41047641 100644 --- a/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/WizardScreen.kt +++ b/core/ui/src/main/kotlin/app/aaps/core/ui/compose/pump/WizardScreen.kt @@ -38,7 +38,9 @@ import app.aaps.core.ui.compose.dialogs.OkCancelDialog * @param currentStep Current wizard step (null hides content) * @param totalSteps Total number of steps for progress indicator * @param currentStepIndex Zero-based current step position - * @param canGoBack Whether back navigation is allowed on current step + * @param canGoBack Whether leaving the wizard is allowed on the current step. When false, the + * toolbar back arrow is hidden and the system back button is swallowed without showing the + * cancel dialog — used by steps where aborting is unsafe (e.g. mid-priming). * @param onBack Called when user confirms exit from the wizard * @param cancelDialogTitle Title for the cancel confirmation dialog * @param cancelDialogText Body text for the cancel confirmation dialog @@ -61,17 +63,21 @@ fun WizardScreen( ) { var showCancelDialog by remember { mutableStateOf(false) } - // Take over toolbar: back arrow shows confirmation dialog + // Take over toolbar: back arrow shows confirmation dialog (hidden while canGoBack is false — + // ViewModels compute per-step canGoBack precisely to lock the user in during irreversible + // steps; this parameter used to be accepted but never wired, silently disabling that gating). if (setToolbarConfig != null) { // Use a stable callback ref so toolbar icon can trigger dialog val onRequestCancel = remember { { showCancelDialog = true } } - DisposableEffect(title, onRequestCancel) { + DisposableEffect(title, onRequestCancel, canGoBack) { setToolbarConfig( ToolbarConfig( title = title, navigationIcon = { - IconButton(onClick = onRequestCancel) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + if (canGoBack) { + IconButton(onClick = onRequestCancel) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } } }, actions = {} @@ -84,9 +90,10 @@ fun WizardScreen( } } - // System back button: always confirm before leaving the wizard + // System back button: confirm before leaving the wizard; swallow entirely on steps that + // must not be left. BackHandler(enabled = true) { - showCancelDialog = true + if (canGoBack) showCancelDialog = true } if (showCancelDialog) { diff --git a/database/impl/src/main/kotlin/app/aaps/database/entities/UserEntry.kt b/database/impl/src/main/kotlin/app/aaps/database/entities/UserEntry.kt index 8916473378c2..23a9712d6d38 100644 --- a/database/impl/src/main/kotlin/app/aaps/database/entities/UserEntry.kt +++ b/database/impl/src/main/kotlin/app/aaps/database/entities/UserEntry.kt @@ -215,6 +215,8 @@ data class UserEntry( BgFragment, Garmin, Database, //for PersistenceLayer + + Carelevo, Unknown, //if necessary ; diff --git a/database/impl/src/main/kotlin/app/aaps/database/entities/embedments/InterfaceIDs.kt b/database/impl/src/main/kotlin/app/aaps/database/entities/embedments/InterfaceIDs.kt index 078c6cb40ca6..9ff1fca081e7 100644 --- a/database/impl/src/main/kotlin/app/aaps/database/entities/embedments/InterfaceIDs.kt +++ b/database/impl/src/main/kotlin/app/aaps/database/entities/embedments/InterfaceIDs.kt @@ -53,6 +53,7 @@ data class InterfaceIDs @Ignore constructor( MEDTRUM_UNTESTED, USER, CACHE, + CARELEVO, EQUIL; companion object { diff --git a/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/PumpTypeExtension.kt b/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/PumpTypeExtension.kt index e17b554e168d..41be4641bfd4 100644 --- a/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/PumpTypeExtension.kt +++ b/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/PumpTypeExtension.kt @@ -42,6 +42,7 @@ fun InterfaceIDs.PumpType.fromDb(): PumpType = InterfaceIDs.PumpType.MEDTRUM_UNTESTED -> PumpType.MEDTRUM_UNTESTED InterfaceIDs.PumpType.CACHE -> PumpType.CACHE InterfaceIDs.PumpType.EQUIL -> PumpType.EQUIL + InterfaceIDs.PumpType.CARELEVO -> PumpType.CAREMEDI_CARELEVO } fun PumpType.toDb(): InterfaceIDs.PumpType = @@ -83,5 +84,6 @@ fun PumpType.toDb(): InterfaceIDs.PumpType = PumpType.MEDTRUM_UNTESTED -> InterfaceIDs.PumpType.MEDTRUM_UNTESTED PumpType.CACHE -> InterfaceIDs.PumpType.CACHE PumpType.EQUIL -> InterfaceIDs.PumpType.EQUIL + PumpType.CAREMEDI_CARELEVO -> InterfaceIDs.PumpType.CARELEVO } diff --git a/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/SourcesExtension.kt b/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/SourcesExtension.kt index c84dedfd19e7..5e544f1ec2f9 100644 --- a/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/SourcesExtension.kt +++ b/database/persistence/src/main/kotlin/app/aaps/database/persistence/converters/SourcesExtension.kt @@ -87,6 +87,7 @@ fun UserEntry.Sources.fromDb(): Sources = UserEntry.Sources.Garmin -> Sources.Garmin UserEntry.Sources.Scene -> Sources.Scene UserEntry.Sources.Database -> Sources.Database + UserEntry.Sources.Carelevo -> Sources.Carelevo UserEntry.Sources.Unknown -> Sources.Unknown } @@ -174,6 +175,7 @@ fun Sources.toDb(): UserEntry.Sources = Sources.Scene -> UserEntry.Sources.Scene Sources.Database -> UserEntry.Sources.Database Sources.Insulin -> UserEntry.Sources.Insulin + Sources.Carelevo -> UserEntry.Sources.Carelevo Sources.Unknown -> UserEntry.Sources.Unknown } diff --git a/implementation/src/main/kotlin/app/aaps/implementation/userEntry/UserEntryPresentationHelperImpl.kt b/implementation/src/main/kotlin/app/aaps/implementation/userEntry/UserEntryPresentationHelperImpl.kt index faeaa91d4f94..cdeff6678232 100644 --- a/implementation/src/main/kotlin/app/aaps/implementation/userEntry/UserEntryPresentationHelperImpl.kt +++ b/implementation/src/main/kotlin/app/aaps/implementation/userEntry/UserEntryPresentationHelperImpl.kt @@ -47,6 +47,7 @@ import app.aaps.core.ui.compose.icons.IcNote import app.aaps.core.ui.compose.icons.IcPatchPump import app.aaps.core.ui.compose.icons.IcPluginAutomation import app.aaps.core.ui.compose.icons.IcPluginAutotune +import app.aaps.core.ui.compose.icons.IcPluginCarelevo import app.aaps.core.ui.compose.icons.IcPluginCombo import app.aaps.core.ui.compose.icons.IcPluginConfigBuilder import app.aaps.core.ui.compose.icons.IcPluginDanaI @@ -109,6 +110,7 @@ class UserEntryPresentationHelperImpl @Inject constructor( Sources.BgFragment -> IcAaps Sources.CalibrationDialog -> IcCalibration Sources.CarbDialog -> IcCarbs + Sources.Carelevo -> IcPluginCarelevo Sources.Combo -> IcPluginCombo Sources.ConcentrationDialog -> IcPluginInsulin Sources.ConfigBuilder -> IcPluginConfigBuilder @@ -267,6 +269,7 @@ class UserEntryPresentationHelperImpl @Inject constructor( Sources.Wear -> ElementType.AAPS.color() Sources.WizardDialog -> ElementType.BOLUS_WIZARD.color() Sources.Xdrip -> ElementType.CGM_XDRIP.color() + Sources.Carelevo -> ElementType.PUMP.color() } override fun listToPresentationString(list: List) = diff --git a/pump/carelevo-emulator/build.gradle.kts b/pump/carelevo-emulator/build.gradle.kts new file mode 100644 index 000000000000..2c8b69a9ea56 --- /dev/null +++ b/pump/carelevo-emulator/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.ksp) + id("android-module-dependencies") + id("test-module-dependencies") + id("jacoco-module-dependencies") +} + +android { + namespace = "app.aaps.pump.carelevo.emulator" +} + +dependencies { + implementation(project(":core:interfaces")) + implementation(project(":pump:carelevo")) + + testImplementation(project(":shared:tests")) + + ksp(libs.com.google.dagger.compiler) + ksp(libs.com.google.dagger.hilt.compiler) + ksp(libs.com.google.dagger.android.processor) +} diff --git a/pump/carelevo-emulator/src/main/kotlin/app/aaps/pump/carelevo/emulator/CarelevoEmulatorBleTransport.kt b/pump/carelevo-emulator/src/main/kotlin/app/aaps/pump/carelevo/emulator/CarelevoEmulatorBleTransport.kt new file mode 100644 index 000000000000..b30b6259f45b --- /dev/null +++ b/pump/carelevo-emulator/src/main/kotlin/app/aaps/pump/carelevo/emulator/CarelevoEmulatorBleTransport.kt @@ -0,0 +1,163 @@ +package app.aaps.pump.carelevo.emulator + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.pump.ble.BleAdapter +import app.aaps.core.interfaces.pump.ble.BleGatt +import app.aaps.core.interfaces.pump.ble.BleScanner +import app.aaps.core.interfaces.pump.ble.BleTransportListener +import app.aaps.core.interfaces.pump.ble.PairingState +import app.aaps.core.interfaces.pump.ble.ScannedDevice +import app.aaps.pump.carelevo.ble.CarelevoBleTransport +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * [CarelevoBleTransport] that talks to a [CarelevoPumpEmulator] instead of real Bluetooth, so the + * whole CareLevo driver — sessions, pairing, commands, coordinators — runs without patch hardware. + * + * The seam is narrow: the driver never touches this directly. `BleTransportGattConnection` adapts it + * to a `GattConnection` and `BleClientImpl` does opcode correlation above that, so everything the + * protocol actually cares about still runs for real. Only the radio is fake. + * + * Each write is answered synchronously on the calling thread, which keeps tests deterministic: by the + * time `writeCharacteristic` returns, the notifications have already been delivered. That is safe + * because `BleClientImpl` registers its waiter *before* writing, so an instantaneous reply cannot race. + * + * The driver opens a fresh session per operation, so `connect`/`close` and `setListener` are exercised + * repeatedly; this tolerates the register → close → register cycle rather than warning about it. + */ +class CarelevoEmulatorBleTransport( + val emulator: CarelevoPumpEmulator = CarelevoPumpEmulator(), + private val aapsLogger: AAPSLogger? = null, + /** Serial reported by a discovery scan. Defaults to whatever the emulated patch already carries. */ + private val serialNumberProvider: (() -> String)? = null +) : CarelevoBleTransport { + + private var listener: BleTransportListener? = null + + @Volatile private var connected = false + + val pumpState: CarelevoPumpState get() = emulator.state + + /** Every frame the driver has written, in order — the assertion surface for tests. */ + val writes: MutableList = mutableListOf() + + /** Bond state of the emulated patch; flip to drive the driver's bonding paths. */ + var bonded: Boolean = true + var bondCalls: Int = 0 + + /** `connect()` returns false, as if the BLE stack refused to start the connection. */ + var connectRefused: Boolean = false + + /** `connect()` succeeds but the patch never reports CONNECTED — drives the connect timeout. */ + var reportConnected: Boolean = true + + override var scanAddress: String? = null + override var onGattError133: (() -> Unit)? = null + + override val adapter: BleAdapter = EmulatorAdapter() + override val scanner: BleScanner = EmulatorScanner() + override val gatt: BleGatt = EmulatorGatt() + + private val _pairingState = MutableStateFlow(PairingState()) + override val pairingState: StateFlow = _pairingState + + override fun updatePairingState(state: PairingState) { + _pairingState.value = state + } + + override fun setListener(listener: BleTransportListener?) { + this.listener = listener + } + + // ---- BleAdapter ------------------------------------------------------------------------- + + private inner class EmulatorAdapter : BleAdapter { + + override fun enable() {} + override fun getDeviceName(address: String): String = deviceName() + override fun isDeviceBonded(address: String): Boolean = bonded + override fun createBond(address: String): Boolean { + bondCalls++ + bonded = true + return true + } + + override fun removeBond(address: String) { + bonded = false + } + } + + // ---- BleScanner ------------------------------------------------------------------------- + + private inner class EmulatorScanner : BleScanner { + + private val _scannedDevices = MutableSharedFlow(replay = 1, extraBufferCapacity = 10) + override val scannedDevices: SharedFlow = _scannedDevices + + override fun startScan() { + // Discovery vs reconnect is decided by isNullOrEmpty(), matching CarelevoBleTransportImpl — + // a null-only check would treat an empty filter as a reconnect and report a blank address. + val filter = scanAddress?.ifEmpty { null } + // A discovery scan (no address filter) is where a fresh patch gets its serial. + if (filter == null) serialNumberProvider?.invoke()?.let { pumpState.serialNumber = it } + val address = filter ?: macAddressString() + aapsLogger?.debug(LTag.PUMPEMULATOR, "emulator: scan reporting $address") + _scannedDevices.tryEmit(ScannedDevice(name = deviceName(), address = address)) + } + + override fun stopScan() {} + } + + // ---- BleGatt ---------------------------------------------------------------------------- + + private inner class EmulatorGatt : BleGatt { + + override fun connect(address: String): Boolean { + if (connectRefused) return false + connected = true + if (reportConnected) listener?.onConnectionStateChanged(true) + return true + } + + override fun disconnect() { + if (connected) { + connected = false + listener?.onConnectionStateChanged(false) + } + } + + override fun close() { + connected = false + } + + override fun discoverServices() { + listener?.onServicesDiscovered(true) + } + + override fun findCharacteristics(): Boolean = true + + override fun enableNotifications() { + listener?.onDescriptorWritten() + } + + override fun writeCharacteristic(data: ByteArray) { + writes += data + listener?.onCharacteristicWritten() + emulator.handle(data).forEach { listener?.onCharacteristicChanged(it) } + } + } + + private fun deviceName(): String = "$DEVICE_NAME_PREFIX${pumpState.serialNumber}" + + private fun macAddressString(): String = + pumpState.macAddress.joinToString(separator = ":") { "%02X".format(it) } + + private companion object { + + const val DEVICE_NAME_PREFIX = "CareLevo-" + } +} diff --git a/pump/carelevo-emulator/src/main/kotlin/app/aaps/pump/carelevo/emulator/CarelevoPumpEmulator.kt b/pump/carelevo-emulator/src/main/kotlin/app/aaps/pump/carelevo/emulator/CarelevoPumpEmulator.kt new file mode 100644 index 000000000000..a1299443cc80 --- /dev/null +++ b/pump/carelevo-emulator/src/main/kotlin/app/aaps/pump/carelevo/emulator/CarelevoPumpEmulator.kt @@ -0,0 +1,544 @@ +package app.aaps.pump.carelevo.emulator + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import kotlin.experimental.xor +import kotlin.math.roundToInt + +/** + * Answers CareLevo BLE commands the way a real patch does, against a live [CarelevoPumpState]. + * + * The CareLevo wire has no framing, length prefix, CRC or encryption: a request is + * `[opcode][payload]` written verbatim, and a response is a raw notification whose byte 0 is the + * opcode the client correlates on. So [handle] is the whole protocol — it takes one written frame + * and returns the notification frames the patch would send back, in order. + * + * Returning a *list* is what makes the two irregular shapes fall out naturally: the 0x33 patch-info + * report is two frames (0x93 + 0x94), and the 0x12 safety check streams N progress frames before its + * terminal one. An empty list means the patch stays silent. + * + * Response opcodes are a hardcoded table, never computed. The rule is *mostly* `request + 0x60` + * (arithmetic — `or 0x60` breaks every opcode from 0x21 up), but there are real exceptions: 0x19→0x7A + * and 0x1A→0x79 are swapped, and 0x4B answers on 0xBB. + */ +class CarelevoPumpEmulator( + val state: CarelevoPumpState = CarelevoPumpState(), + private val aapsLogger: AAPSLogger? = null +) { + + /** Frames the patch sends back for [request]; empty when it stays silent. */ + fun handle(request: ByteArray): List { + if (request.isEmpty()) return emptyList() + val opcode = request[0] + if (opcode in state.silentOpcodes) { + aapsLogger?.debug(LTag.PUMPEMULATOR, "emulator: silently dropping 0x${hex(opcode)}") + return emptyList() + } + val responses = dispatch(opcode, request) + aapsLogger?.debug( + LTag.PUMPEMULATOR, + "emulator: 0x${hex(opcode)} -> ${responses.joinToString(", ") { "0x${hex(it[0])}" }.ifEmpty { "(silence)" }}" + ) + return responses + } + + private fun dispatch(opcode: Byte, request: ByteArray): List = when (opcode) { + OP_SET_TIME -> setTime(request) + OP_SAFETY_CHECK -> safetyCheck() + OP_BASAL_SET -> basalProgram(request, RESP_BASAL_SET) + OP_BASAL_UPDATE -> basalProgram(request, RESP_BASAL_UPDATE) + OP_NOTICE_THRESHOLD -> noticeThreshold(request) + OP_INFUSION_THRESHOLD -> infusionThreshold(request) + OP_BUZZ_MODE -> buzzMode(request) + OP_NEEDLE_ACK -> needleAck(request) + OP_NEEDLE_STATUS -> needleStatus() + OP_THRESHOLD_SETUP -> thresholdSetup(request) + OP_ADDITIONAL_PRIMING -> additionalPriming() + OP_TEMP_BASAL -> tempBasal() + OP_IMMEDIATE_BOLUS -> immediateBolus(request) + OP_EXTEND_BOLUS -> extendBolus(request) + OP_PUMP_STOP -> pumpStop(request) + OP_PUMP_RESUME -> pumpResume(request) + OP_EXTEND_BOLUS_CANCEL -> extendBolusCancel() + OP_BOLUS_CANCEL -> bolusCancel() + OP_TEMP_BASAL_CANCEL -> tempBasalCancel() + OP_INFUSION_INFO -> infusionInfo(request) + OP_PATCH_INFO -> patchInfoFrames() + OP_PATCH_DISCARD -> patchDiscard() + OP_MAC_ADDRESS -> macAddress(request) + OP_ALARM_CLEAR -> alarmClear(request) + OP_ALERT_ALARM_SET -> alertAlarmSet(request) + OP_APP_AUTH -> appAuth(request) + else -> { + aapsLogger?.debug(LTag.PUMPEMULATOR, "emulator: unknown opcode 0x${hex(opcode)}") + emptyList() + } + } + + // ---- Pairing ---------------------------------------------------------------------------- + + /** `[0x9B][mac 6][checksum ..]`. The key is retained so [appAuth] can verify the app's fold. */ + private fun macAddress(request: ByteArray): List { + state.lastMacKey = if (request.size > 1) request.u(1) else null + return listOf(byteArrayOf(RESP_MAC_ADDRESS) + state.macAddress + state.macCheckSum) + } + + /** + * Verifies the app's handshake rather than rubber-stamping it: the app XOR-folds + * `(mac || checksum)` starting from the key it sent on 0x3B, so the same fold here must match. + * A real patch rejects a wrong fold, and so does this — that is the point of emulating it. + */ + private fun appAuth(request: ByteArray): List { + val key = state.lastMacKey + val expected = key?.let { (state.macAddress + state.macCheckSum).fold(it.toByte()) { acc, b -> acc xor b } } + val offered = if (request.size > 1) request[1] else null + val result = when { + state.resultCodeOverrides.containsKey(OP_APP_AUTH) -> state.resultFor(OP_APP_AUTH) + expected == null || offered == null -> AUTH_FAILED + offered != expected -> { + aapsLogger?.debug( + LTag.PUMPEMULATOR, + "emulator: auth rejected, expected 0x${hex(expected)} got 0x${hex(offered)}" + ) + AUTH_FAILED + } + + else -> CarelevoPumpState.RESULT_SUCCESS + } + return listOf(byteArrayOf(RESP_APP_AUTH, result.toByte())) + } + + // ---- Time and patch info ---------------------------------------------------------------- + + /** + * 0x11 carries a byte-identical frame for two different commands; only `subId` (byte 1) tells + * them apart. `subId == 0` is the activation path, which waits for the patch-info pair and would + * hang on a 0x71; every other subId is a plain set-time that wants the 0x71 ack. + */ + private fun setTime(request: ByteArray): List { + val subId = if (request.size > 1) request.u(1) else 0 + val result = state.resultFor(OP_SET_TIME) + if (request.size >= SET_TIME_LENGTH && result == CarelevoPumpState.RESULT_SUCCESS) { + state.subId = subId + state.runningMinutes = 0 + } + return if (subId == 0) patchInfoFrames() + else listOf(byteArrayOf(RESP_SET_TIME, result.toByte())) + } + + /** + * RPT1 `[0x93][result][serial 13 ASCII]` and RPT2 `[0x94][result][fw 4 ASCII][unused 5][model 5]`. + * Order is irrelevant — the client keys them by opcode — but both must arrive or the request hangs. + */ + private fun patchInfoFrames(): List { + val result = state.resultFor(OP_PATCH_INFO).toByte() + val rpt1 = byteArrayOf(RESP_PATCH_INFO_RPT1, result) + state.serialNumber.ascii(SERIAL_LENGTH) + val rpt2 = byteArrayOf(RESP_PATCH_INFO_RPT2, result) + + state.firmwareVersion.ascii(FIRMWARE_LENGTH) + + ByteArray(RPT2_UNUSED_LENGTH) + + state.modelBytes.copyOf(MODEL_LENGTH) + return listOf(rpt1, rpt2) + } + + // ---- Safety check ----------------------------------------------------------------------- + + /** + * Streams progress frames then a terminal one, all on 0x72. A frame is progress purely because + * its result byte is 4 or 18; anything else — including 0 for success — ends the stream. + * + * Frames are 6 bytes. Never emit 5: `SafetyCheckCommand.decode` reads index 5 whenever + * `size > 4`, so a 5-byte frame throws out of bounds instead of failing cleanly. + */ + private fun safetyCheck(): List { + val frames = mutableListOf() + repeat(state.safetyCheckProgressFrames) { frames += safetyFrame(SAFETY_PROGRESS_RESULT) } + frames += safetyFrame(state.safetyCheckResult) + if (state.safetyCheckResult == CarelevoPumpState.RESULT_SUCCESS) state.pumpStateRaw = PUMP_STATE_RUNNING + return frames + } + + private fun safetyFrame(result: Int): ByteArray = byteArrayOf( + RESP_SAFETY_CHECK, + result.toByte(), + (state.safetyCheckVolume / HUNDRED).toByte(), + (state.safetyCheckVolume % HUNDRED).toByte(), + (state.safetyCheckDurationSeconds / SECONDS_PER_MINUTE).toByte(), + (state.safetyCheckDurationSeconds % SECONDS_PER_MINUTE).toByte() + ) + + // ---- Basal ------------------------------------------------------------------------------ + + /** + * Each sequence is its own round-trip: the ack carries no seqNo, so the pump simply banks the + * segments and answers `[resp][result]`. The program only counts as set once seq 2 lands. + */ + private fun basalProgram(request: ByteArray, responseOpcode: Byte): List { + val seqNo = if (request.size > 1) request.u(1) else 0 + val speeds = (BASAL_SEGMENT_START until request.size step 2) + .filter { it + 1 < request.size } + .map { request.u(it) + request.u(it + 1) / CENTI } + val requestOpcode = request[0] + val result = state.resultFor(requestOpcode) + if (result == CarelevoPumpState.RESULT_SUCCESS) { + state.basalSegmentsBySeq[seqNo] = speeds + if (seqNo == BASAL_FINAL_SEQ) state.basalProgramCommitted = true + } + return listOf(byteArrayOf(responseOpcode, result.toByte())) + } + + // ---- Settings --------------------------------------------------------------------------- + + /** `[0x75][type]` — byte 1 echoes the type; there is no result code, arrival means success. */ + private fun noticeThreshold(request: ByteArray): List { + val type = if (request.size > 1) request.u(1) else 0 + val value = if (request.size > 2) request.u(2) else 0 + when (type) { + NOTICE_TYPE_LOW_INSULIN -> state.lowInsulinNotice = value + NOTICE_TYPE_EXPIRY -> state.expiryNotice = value + } + return listOf(byteArrayOf(RESP_NOTICE_THRESHOLD, type.toByte())) + } + + /** `[0x77][type][result]` — result sits at index 2 here, because byte 1 echoes the type. */ + private fun infusionThreshold(request: ByteArray): List { + val flag = if (request.size > 1) request[1] else 0 + val result = state.resultFor(OP_INFUSION_THRESHOLD) + if (request.size >= 4 && result == CarelevoPumpState.RESULT_SUCCESS) { + val value = request.u(2) + request.u(3) / CENTI + if (flag == FLAG_MAX_VOLUME) state.maxBolusDose = value else state.maxBasalSpeed = value + } + return listOf(byteArrayOf(RESP_INFUSION_THRESHOLD, flag, result.toByte())) + } + + private fun buzzMode(request: ByteArray): List { + val result = state.resultFor(OP_BUZZ_MODE) + if (request.size > 1 && result == CarelevoPumpState.RESULT_SUCCESS) state.buzzUse = request[1] == FLAG_ON + return listOf(byteArrayOf(RESP_BUZZ_MODE, result.toByte())) + } + + private fun thresholdSetup(request: ByteArray): List { + val result = state.resultFor(OP_THRESHOLD_SETUP) + if (request.size >= THRESHOLD_SETUP_LENGTH && result == CarelevoPumpState.RESULT_SUCCESS) { + state.insulinRemainsThreshold = request.u(1) + state.expiryThreshold = request.u(2) + state.maxBasalSpeed = request.u(3) + request.u(4) / CENTI + state.maxBolusDose = request.u(5) + request.u(6) / CENTI + state.buzzUse = request[7] == FLAG_ON + } + return listOf(byteArrayOf(RESP_THRESHOLD_SETUP, result.toByte())) + } + + private fun alertAlarmSet(request: ByteArray): List { + val result = state.resultFor(OP_ALERT_ALARM_SET) + if (request.size > 1 && result == CarelevoPumpState.RESULT_SUCCESS) state.alertAlarmMode = request.u(1) + return listOf(byteArrayOf(RESP_ALERT_ALARM_SET, result.toByte())) + } + + /** `[0xA7][subId][cause][result]` — result at index 3. */ + private fun alarmClear(request: ByteArray): List { + val alarmType = if (request.size > 1) request[1] else 0 + val cause = if (request.size > 2) request[2] else 0 + return listOf(byteArrayOf(RESP_ALARM_CLEAR, alarmType, cause, state.resultFor(OP_ALARM_CLEAR).toByte())) + } + + // ---- Needle and priming ----------------------------------------------------------------- + + /** 0x19 answers on 0x7A — swapped with [needleStatus]. Request byte 1: 0x00 success, 0x01 failure. */ + private fun needleAck(request: ByteArray): List { + val result = state.resultFor(OP_NEEDLE_ACK) + if (request.size > 1 && request[1] == NEEDLE_FLAG_SUCCESS && result == CarelevoPumpState.RESULT_SUCCESS) { + state.needleInserted = true + } + return listOf(byteArrayOf(RESP_NEEDLE_ACK, result.toByte())) + } + + /** 0x1A answers on 0x79 — swapped with [needleAck]. */ + private fun needleStatus(): List = + listOf(byteArrayOf(RESP_NEEDLE_STATUS, state.resultFor(OP_NEEDLE_STATUS).toByte())) + + private fun additionalPriming(): List { + val result = state.resultFor(OP_ADDITIONAL_PRIMING) + if (result == CarelevoPumpState.RESULT_SUCCESS) state.primingCount++ + return listOf(byteArrayOf(RESP_ADDITIONAL_PRIMING, result.toByte())) + } + + // ---- Delivery --------------------------------------------------------------------------- + + /** + * The ack is result-only, so the emulator does not need to distinguish the two modes — worth + * knowing that they are told apart by frame length alone (6 bytes BY_UNIT with a trailing 0x00, + * 5 bytes BY_PERCENT), since the opcode and layout are otherwise identical. + */ + private fun tempBasal(): List { + val result = state.resultFor(OP_TEMP_BASAL) + if (result == CarelevoPumpState.RESULT_SUCCESS) { + state.tempBasalRunning = true + state.modeRaw = MODE_TEMP_BASAL + } + return listOf(byteArrayOf(RESP_TEMP_BASAL, result.toByte())) + } + + private fun tempBasalCancel(): List { + val result = state.resultFor(OP_TEMP_BASAL_CANCEL) + if (result == CarelevoPumpState.RESULT_SUCCESS) { + state.tempBasalRunning = false + state.modeRaw = MODE_BASAL + } + return listOf(byteArrayOf(RESP_TEMP_BASAL_CANCEL, result.toByte())) + } + + /** + * `[0x84][actionId][result][mm][ss][remain 3]`. Byte 1 **must** echo the request's actionId — + * the client correlates on it and silently discards a frame that does not match, hanging the call. + */ + private fun immediateBolus(request: ByteArray): List { + val actionId = if (request.size > 1) request[1] else 0 + val volume = if (request.size > 3) request.u(2) + request.u(3) / CENTI else 0.0 + val result = state.resultFor(OP_IMMEDIATE_BOLUS) + if (result == CarelevoPumpState.RESULT_SUCCESS) { + state.activeBolusActionId = actionId.toUByte().toInt() + state.modeRaw = MODE_IMME_BOLUS + state.insulinRemaining = (state.insulinRemaining - volume).coerceAtLeast(0.0) + state.infusedTotalBolus += volume + // The emulated bolus lands immediately rather than ramping, so the whole dose counts as + // infused — a later cancel must report that, not 0 U for insulin the totals already hold. + state.bolusInfusedAmount = volume + } + val seconds = (volume * SECONDS_PER_UNIT).roundToInt() + return listOf( + byteArrayOf( + RESP_IMMEDIATE_BOLUS, + actionId, + result.toByte(), + (seconds / SECONDS_PER_MINUTE).toByte(), + (seconds % SECONDS_PER_MINUTE).toByte() + ) + encodeHundredsUnitsCenti(state.insulinRemaining) + ) + } + + /** `[0x8C][result][units][centi]` — the infused amount is 2 bytes here, with no hundreds byte. */ + private fun bolusCancel(): List { + val result = state.resultFor(OP_BOLUS_CANCEL) + val infused = state.bolusInfusedAmount + if (result == CarelevoPumpState.RESULT_SUCCESS) { + state.activeBolusActionId = null + state.modeRaw = MODE_BASAL + } + return listOf(byteArrayOf(RESP_BOLUS_CANCEL, result.toByte()) + encodeUnitsCenti(infused)) + } + + /** + * `[0x85][result][mm][ss]`. The decoder recombines bytes 2..3 as `[2] * 60 + [3]` **seconds**, so + * the requested duration goes into byte 2 as whole minutes — halving it again by 60 would report + * a 90-minute bolus as 90 seconds. + * + * Byte 2 caps the reportable duration at 255 minutes; a longer request is clamped rather than + * silently wrapping, which is the protocol's limit and not something the emulator can widen. + */ + private fun extendBolus(request: ByteArray): List { + val result = state.resultFor(OP_EXTEND_BOLUS) + val hour = if (request.size > 5) request.u(5) else 0 + val min = if (request.size > 6) request.u(6) else 0 + if (result == CarelevoPumpState.RESULT_SUCCESS) { + state.extendedBolusRunning = true + state.extendedInfusedAmount = 0.0 + state.modeRaw = MODE_EXTEND_BOLUS + } + val totalMinutes = (hour * MINUTES_PER_HOUR + min).coerceAtMost(MAX_REPORTABLE_MINUTES) + return listOf(byteArrayOf(RESP_EXTEND_BOLUS, result.toByte(), totalMinutes.toByte(), 0)) + } + + private fun extendBolusCancel(): List { + val result = state.resultFor(OP_EXTEND_BOLUS_CANCEL) + val infused = state.extendedInfusedAmount + if (result == CarelevoPumpState.RESULT_SUCCESS) { + state.extendedBolusRunning = false + state.modeRaw = MODE_BASAL + } + return listOf(byteArrayOf(RESP_EXTEND_BOLUS_CANCEL, result.toByte()) + encodeUnitsCenti(infused)) + } + + // ---- Stop / resume / discard ------------------------------------------------------------ + + private fun pumpStop(request: ByteArray): List { + val result = state.resultFor(OP_PUMP_STOP) + if (result == CarelevoPumpState.RESULT_SUCCESS) { + state.stopped = true + if (request.size > 3) state.subId = request.u(3) + } + return listOf(byteArrayOf(RESP_PUMP_STOP, result.toByte())) + } + + /** `[0x87][result][mode]` — the optional 4th subId byte is left off; the decoder defaults it to 0. */ + private fun pumpResume(request: ByteArray): List { + val mode = if (request.size > 1) request[1] else 0 + val result = state.resultFor(OP_PUMP_RESUME) + if (result == CarelevoPumpState.RESULT_SUCCESS) { + state.stopped = false + state.modeRaw = MODE_BASAL + } + return listOf(byteArrayOf(RESP_PUMP_RESUME, result.toByte(), mode)) + } + + private fun patchDiscard(): List { + val result = state.resultFor(OP_PATCH_DISCARD) + if (result == CarelevoPumpState.RESULT_SUCCESS) { + state.discarded = true + state.pumpStateRaw = PUMP_STATE_READY + } + return listOf(byteArrayOf(RESP_PATCH_DISCARD, result.toByte())) + } + + // ---- Status ----------------------------------------------------------------------------- + + /** + * The 20-byte status frame. Bytes 13..14 are documented but never decoded — they still have to be + * present, because the decoder reads index 19 and requires the full length. + */ + private fun infusionInfo(request: ByteArray): List { + val inquiryType = if (request.size > 1) request.u(1) else 0 + val frame = ByteArray(INFUSION_INFO_LENGTH) + frame[0] = RESP_INFUSION_INFO + frame[1] = inquiryType.toByte() + frame[2] = (state.runningMinutes / MINUTES_PER_HOUR).toByte() + frame[3] = (state.runningMinutes % MINUTES_PER_HOUR).toByte() + encodeHundredsUnitsCenti(state.insulinRemaining).copyInto(frame, 4) + encodeUnitsCenti(state.infusedTotalBasal).copyInto(frame, 7) + encodeUnitsCenti(state.infusedTotalBolus).copyInto(frame, 9) + frame[11] = state.pumpStateRaw.toByte() + frame[12] = state.modeRaw.toByte() + // 13..14 (infuseSetMinutes) intentionally left zero — present but never read. + encodeUnitsCenti(state.infusedTotalBolus).copyInto(frame, 15) + frame[17] = (state.runningMinutes / MINUTES_PER_HOUR).toByte() + frame[18] = (state.runningMinutes % MINUTES_PER_HOUR).toByte() + frame[19] = 0 + return listOf(frame) + } + + // ---- Encoding helpers ------------------------------------------------------------------- + + /** `[hundreds, units, centi]` — the 3-byte reservoir encoding of 0x91[4..6] and 0x84[5..7]. */ + private fun encodeHundredsUnitsCenti(value: Double): ByteArray { + val centiTotal = (value * HUNDRED).roundToInt().coerceAtLeast(0) + return byteArrayOf( + (centiTotal / TEN_THOUSAND).toByte(), + ((centiTotal / HUNDRED) % HUNDRED).toByte(), + (centiTotal % HUNDRED).toByte() + ) + } + + /** `[units, centi]` — the 2-byte amount encoding of the 0x91 totals and the 0x89/0x8C acks. */ + private fun encodeUnitsCenti(value: Double): ByteArray { + val centiTotal = (value * HUNDRED).roundToInt().coerceAtLeast(0) + return byteArrayOf((centiTotal / HUNDRED).toByte(), (centiTotal % HUNDRED).toByte()) + } + + /** Fixed-width ASCII, space-padded or truncated — the frame layout is positional. */ + private fun String.ascii(length: Int): ByteArray = + padEnd(length).take(length).toByteArray(Charsets.US_ASCII) + + private fun ByteArray.u(index: Int): Int = this[index].toUByte().toInt() + + private fun hex(value: Byte): String = "%02X".format(value) + + @Suppress("unused") + companion object { + + // Requests + const val OP_SET_TIME: Byte = 0x11 + const val OP_SAFETY_CHECK: Byte = 0x12 + const val OP_BASAL_SET: Byte = 0x13 + const val OP_NOTICE_THRESHOLD: Byte = 0x15 + const val OP_INFUSION_THRESHOLD: Byte = 0x17 + const val OP_BUZZ_MODE: Byte = 0x18 + const val OP_NEEDLE_ACK: Byte = 0x19 + const val OP_NEEDLE_STATUS: Byte = 0x1A + const val OP_THRESHOLD_SETUP: Byte = 0x1B + const val OP_ADDITIONAL_PRIMING: Byte = 0x1D + const val OP_BASAL_UPDATE: Byte = 0x21 + const val OP_TEMP_BASAL: Byte = 0x23 + const val OP_IMMEDIATE_BOLUS: Byte = 0x24 + const val OP_EXTEND_BOLUS: Byte = 0x25 + const val OP_PUMP_STOP: Byte = 0x26 + const val OP_PUMP_RESUME: Byte = 0x27 + const val OP_EXTEND_BOLUS_CANCEL: Byte = 0x29 + const val OP_BOLUS_CANCEL: Byte = 0x2C + const val OP_TEMP_BASAL_CANCEL: Byte = 0x2D + const val OP_INFUSION_INFO: Byte = 0x31 + const val OP_PATCH_INFO: Byte = 0x33 + const val OP_PATCH_DISCARD: Byte = 0x36 + const val OP_MAC_ADDRESS: Byte = 0x3B + const val OP_ALARM_CLEAR: Byte = 0x47 + const val OP_ALERT_ALARM_SET: Byte = 0x48 + const val OP_APP_AUTH: Byte = 0x4B + + // Responses — hardcoded, never computed from the request. + const val RESP_SET_TIME: Byte = 0x71 + const val RESP_SAFETY_CHECK: Byte = 0x72 + const val RESP_BASAL_SET: Byte = 0x73 + const val RESP_NOTICE_THRESHOLD: Byte = 0x75 + const val RESP_INFUSION_THRESHOLD: Byte = 0x77 + const val RESP_BUZZ_MODE: Byte = 0x78 + const val RESP_NEEDLE_STATUS: Byte = 0x79 // 0x1A -> 0x79, swapped with the ack below + const val RESP_NEEDLE_ACK: Byte = 0x7A // 0x19 -> 0x7A + const val RESP_THRESHOLD_SETUP: Byte = 0x7B + const val RESP_ADDITIONAL_PRIMING: Byte = 0x7D + val RESP_BASAL_UPDATE: Byte = 0x81.toByte() + val RESP_TEMP_BASAL: Byte = 0x83.toByte() + val RESP_IMMEDIATE_BOLUS: Byte = 0x84.toByte() + val RESP_EXTEND_BOLUS: Byte = 0x85.toByte() + val RESP_PUMP_STOP: Byte = 0x86.toByte() + val RESP_PUMP_RESUME: Byte = 0x87.toByte() + val RESP_EXTEND_BOLUS_CANCEL: Byte = 0x89.toByte() + val RESP_BOLUS_CANCEL: Byte = 0x8C.toByte() + val RESP_TEMP_BASAL_CANCEL: Byte = 0x8D.toByte() + val RESP_INFUSION_INFO: Byte = 0x91.toByte() + val RESP_PATCH_INFO_RPT1: Byte = 0x93.toByte() + val RESP_PATCH_INFO_RPT2: Byte = 0x94.toByte() + val RESP_PATCH_DISCARD: Byte = 0x96.toByte() + val RESP_MAC_ADDRESS: Byte = 0x9B.toByte() + val RESP_ALARM_CLEAR: Byte = 0xA7.toByte() + val RESP_ALERT_ALARM_SET: Byte = 0xA8.toByte() + val RESP_APP_AUTH: Byte = 0xBB.toByte() // 0x4B -> 0xBB, not 0xAB and not 0xBA + + /** Result byte of a 0x72 frame that means "still working" — see `SafetyCheckCommand`. */ + const val SAFETY_PROGRESS_RESULT = 4 + const val AUTH_FAILED = 1 + + const val PUMP_STATE_READY = 0 + const val PUMP_STATE_RUNNING = 2 + + const val MODE_BASAL = 1 + const val MODE_TEMP_BASAL = 2 + const val MODE_IMME_BOLUS = 3 + const val MODE_EXTEND_BOLUS = 5 + + const val FLAG_ON: Byte = 0x01 + const val FLAG_MAX_VOLUME: Byte = 0x01 + const val NEEDLE_FLAG_SUCCESS: Byte = 0x00 + const val NOTICE_TYPE_LOW_INSULIN = 0 + const val NOTICE_TYPE_EXPIRY = 1 + + const val SET_TIME_LENGTH = 11 + const val THRESHOLD_SETUP_LENGTH = 8 + const val INFUSION_INFO_LENGTH = 20 + const val SERIAL_LENGTH = 13 + const val FIRMWARE_LENGTH = 4 + const val MODEL_LENGTH = 5 + const val RPT2_UNUSED_LENGTH = 5 + const val BASAL_SEGMENT_START = 2 + const val BASAL_FINAL_SEQ = 2 + + const val HUNDRED = 100 + const val TEN_THOUSAND = 10_000 + const val CENTI = 100.0 + const val SECONDS_PER_MINUTE = 60 + const val MINUTES_PER_HOUR = 60 + + /** Rough delivery rate used to fabricate the 0x84 expected-duration bytes. */ + const val SECONDS_PER_UNIT = 20 + + /** Byte 2 of the 0x85 ack carries whole minutes, so the wire cannot report beyond this. */ + const val MAX_REPORTABLE_MINUTES = 255 + } +} diff --git a/pump/carelevo-emulator/src/main/kotlin/app/aaps/pump/carelevo/emulator/CarelevoPumpState.kt b/pump/carelevo-emulator/src/main/kotlin/app/aaps/pump/carelevo/emulator/CarelevoPumpState.kt new file mode 100644 index 000000000000..6fb84af37232 --- /dev/null +++ b/pump/carelevo-emulator/src/main/kotlin/app/aaps/pump/carelevo/emulator/CarelevoPumpState.kt @@ -0,0 +1,123 @@ +package app.aaps.pump.carelevo.emulator + +/** + * Mutable state of the emulated CareLevo patch. + * + * The emulator answers status queries from here rather than from canned frames, so the driver sees a + * patch that remembers what it was told: a basal program written over three sequences, a bolus that + * draws the reservoir down, a stop that reports itself as stopped. + * + * Raw codes mirror `CarelevoBtEnums` and are deliberately raw (not enums) because they go on the wire: + * - [pumpStateRaw]: 0 READY, 1 PRIMING, 2 RUNNING — anything else decodes to ERROR + * - [modeRaw]: 1 BASAL, 2 TEMP_BASAL, 3 IMME_BOLUS, 4 EXTEND_IMME_BOLUS, 5 EXTEND_BOLUS. + * **0 decodes to ERROR**, so it is never a resting value. + */ +class CarelevoPumpState { + + // ---- Identity --------------------------------------------------------------------------- + + /** Exactly 13 chars — occupies bytes 2..14 of the 0x93 report, one ASCII byte per char. */ + var serialNumber: String = "CL24000000001" + + /** Exactly 4 chars — bytes 2..5 of the 0x94 report, ASCII. */ + var firmwareVersion: String = "1.10" + + /** + * Five raw bytes at 0x94[11..15]. The decoder concatenates each byte's DECIMAL value, so + * `[1, 2, 0, 0, 0]` reads back as the model name "12000" — it is not ASCII. + */ + var modelBytes: ByteArray = byteArrayOf(1, 2, 0, 0, 0) + + /** Six raw bytes, MSB first; surfaces to the driver as the uppercase hex "94B2161D2F6D". */ + var macAddress: ByteArray = byteArrayOf(0x94.toByte(), 0xB2.toByte(), 0x16, 0x1D, 0x2F, 0x6D) + + /** Trailing checksum bytes of the 0x9B frame. Content is arbitrary — see [lastMacKey]. */ + var macCheckSum: ByteArray = byteArrayOf(0x5A) + + /** Key from the last 0x3B request, retained so 0x4B can verify the app's XOR fold. */ + var lastMacKey: Int? = null + + // ---- Reservoir and totals --------------------------------------------------------------- + + /** Units remaining. Reported by 0x91 and by the 0x84 bolus ack. */ + var insulinRemaining: Double = 200.0 + var infusedTotalBasal: Double = 0.0 + var infusedTotalBolus: Double = 0.0 + + // ---- Lifecycle -------------------------------------------------------------------------- + + var pumpStateRaw: Int = 2 + var modeRaw: Int = 1 + var subId: Int = 0 + var runningMinutes: Int = 0 + var stopped: Boolean = false + var discarded: Boolean = false + var needleInserted: Boolean = false + var primingCount: Int = 0 + + // ---- Basal ------------------------------------------------------------------------------ + + /** + * Segment speeds accumulated per sequence number. A full program is three independent writes + * (seq 0, 1, 2), each separately acked; [basalProgramCommitted] flips only once seq 2 lands. + */ + val basalSegmentsBySeq: MutableMap> = mutableMapOf() + var basalProgramCommitted: Boolean = false + + /** Set of the whole program, in segment order, once all three sequences have arrived. */ + val basalProgram: List + get() = (0..2).flatMap { basalSegmentsBySeq[it].orEmpty() } + + // ---- Temp basal / bolus ----------------------------------------------------------------- + + var tempBasalRunning: Boolean = false + var extendedBolusRunning: Boolean = false + + /** actionId of the in-flight immediate bolus; echoed back on 0x84 for correlation. */ + var activeBolusActionId: Int? = null + + /** Units already delivered by the in-flight bolus — reported by the 0x8C cancel ack. */ + var bolusInfusedAmount: Double = 0.0 + var extendedInfusedAmount: Double = 0.0 + + // ---- Thresholds and settings ------------------------------------------------------------ + + var insulinRemainsThreshold: Int = 20 + var expiryThreshold: Int = 72 + var maxBasalSpeed: Double = 15.0 + var maxBolusDose: Double = 25.0 + var buzzUse: Boolean = false + var lowInsulinNotice: Int = 20 + var expiryNotice: Int = 72 + var alertAlarmMode: Int = 0 + + // ---- Safety check ----------------------------------------------------------------------- + + /** Progress frames the 0x12 stream emits before its terminal frame. */ + var safetyCheckProgressFrames: Int = 2 + + /** Priming volume reported by the safety-check stream, in whole units. */ + var safetyCheckVolume: Int = 210 + + /** Priming duration reported by the safety-check stream, in seconds. */ + var safetyCheckDurationSeconds: Int = 210 + + /** Terminal result of the safety-check stream. 0 = success; see `SafetyCheckCommand`. */ + var safetyCheckResult: Int = 0 + + // ---- Fault injection -------------------------------------------------------------------- + + /** Opcodes the patch silently ignores, so a caller's timeout is the only way out. */ + val silentOpcodes: MutableSet = mutableSetOf() + + /** Per-opcode result-code override, for driving the driver's error paths. */ + val resultCodeOverrides: MutableMap = mutableMapOf() + + /** Result code this opcode should answer with — 0 (success) unless overridden. */ + fun resultFor(requestOpcode: Byte): Int = resultCodeOverrides[requestOpcode] ?: RESULT_SUCCESS + + companion object { + + const val RESULT_SUCCESS = 0 + } +} diff --git a/pump/carelevo-emulator/src/test/kotlin/app/aaps/pump/carelevo/emulator/CarelevoEmulatorTransportTest.kt b/pump/carelevo-emulator/src/test/kotlin/app/aaps/pump/carelevo/emulator/CarelevoEmulatorTransportTest.kt new file mode 100644 index 000000000000..ff54c8b90e22 --- /dev/null +++ b/pump/carelevo-emulator/src/test/kotlin/app/aaps/pump/carelevo/emulator/CarelevoEmulatorTransportTest.kt @@ -0,0 +1,370 @@ +package app.aaps.pump.carelevo.emulator + +import app.aaps.core.interfaces.pump.ble.BleTransportListener +import app.aaps.core.interfaces.pump.ble.PairingState +import app.aaps.core.interfaces.pump.ble.PairingStep +import app.aaps.pump.carelevo.ble.commands.InfusionInfoCommand +import app.aaps.pump.carelevo.ble.commands.PatchDiscardCommand +import app.aaps.pump.carelevo.ble.commands.PatchInfoCommand +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * Transport tests for [CarelevoEmulatorBleTransport] — the seam between the driver's `BleTransport` + * contract and [CarelevoPumpEmulator]. + * + * Scope is deliberately the *transport*, not the protocol: [CarelevoPumpEmulatorTest] already proves + * every opcode round-trips through the production codec, so nothing here re-asserts frame contents + * beyond the response opcode needed to tell one notification from the next. What this suite pins down + * is the wiring the emulator adds on top: + * - which listener callback each `gatt` call fans out to, and **in what order** — `onCharacteristicWritten` + * must precede the `onCharacteristicChanged` frames, because [app.aaps.core.interfaces.pump.ble.BleGatt] + * answers writes synchronously and a client that saw the reply before the ack would be reading a + * reply to a write it does not yet believe it made; + * - the fault-injection switches (`connectRefused`, `reportConnected`, `silentOpcodes`) that exist + * purely to drive the driver's failure paths; + * - the register → close → register cycle the driver performs once per operation. + * + * Pure JVM: the transport takes a nullable logger and touches no Android API, so no Robolectric. + */ +class CarelevoEmulatorTransportTest { + + private lateinit var state: CarelevoPumpState + private lateinit var emulator: CarelevoPumpEmulator + private lateinit var transport: CarelevoEmulatorBleTransport + private lateinit var listener: RecordingListener + + @BeforeEach + fun setUp() { + state = CarelevoPumpState() + emulator = CarelevoPumpEmulator(state) + transport = CarelevoEmulatorBleTransport(emulator) + listener = RecordingListener() + transport.setListener(listener) + } + + // ---- Listener fan-out -------------------------------------------------------------------- + + @Test + fun `the connect handshake fans out to each listener callback in order`() { + transport.gatt.connect(ADDRESS) + transport.gatt.discoverServices() + val found = transport.gatt.findCharacteristics() + transport.gatt.enableNotifications() + + // The driver's session drives exactly this sequence; each step must land on its own callback. + assertThat(listener.events).containsExactly(CONNECTED, SERVICES_OK, DESCRIPTOR).inOrder() + // findCharacteristics is synchronous — it answers by return value, not by a callback. + assertThat(found).isTrue() + } + + @Test + fun `a write acks before it delivers the notification`() { + transport.gatt.connect(ADDRESS) + listener.clear() + + transport.gatt.writeCharacteristic(PatchDiscardCommand().encode()) + + // Order is the point: the ack must come first, or a client would see a reply to a write it + // does not yet believe completed. + assertThat(listener.events).containsExactly(WRITTEN, CHANGED).inOrder() + assertThat(listener.notifications.single()[0]).isEqualTo(PatchDiscardCommand.RESPONSE_OPCODE) + } + + @Test + fun `a multi-frame command delivers one notification per emulator frame in order`() { + transport.gatt.connect(ADDRESS) + listener.clear() + + transport.gatt.writeCharacteristic(PatchInfoCommand().encode()) + + // 0x33 is answered by two report frames; both must arrive as separate notifications, in order, + // or the client's multi-frame correlation never completes. + assertThat(listener.events).containsExactly(WRITTEN, CHANGED, CHANGED).inOrder() + assertThat(listener.notifications.map { it[0] }) + .containsExactly(PatchInfoCommand.RPT1_OPCODE, PatchInfoCommand.RPT2_OPCODE) + .inOrder() + } + + @Test + fun `a silent opcode still acks the write but delivers no notification`() { + state.silentOpcodes += InfusionInfoCommand.REQUEST_OPCODE + transport.gatt.connect(ADDRESS) + listener.clear() + + transport.gatt.writeCharacteristic(InfusionInfoCommand().encode()) + + // The write itself still completes at the GATT layer — only the patch goes quiet, which is + // what leaves the caller waiting on its timeout. + assertThat(listener.events).containsExactly(WRITTEN) + assertThat(listener.notifications).isEmpty() + // ...and the frame is still recorded, so a test can assert what was sent into the silence. + assertThat(transport.writes).hasSize(1) + } + + @Test + fun `writes records every frame in order`() { + val patchInfo = PatchInfoCommand().encode() + val infusionInfo = InfusionInfoCommand().encode() + val discard = PatchDiscardCommand().encode() + + transport.gatt.connect(ADDRESS) + transport.gatt.writeCharacteristic(patchInfo) + transport.gatt.writeCharacteristic(infusionInfo) + transport.gatt.writeCharacteristic(discard) + + assertThat(transport.writes.map { it.toList() }) + .containsExactly(patchInfo.toList(), infusionInfo.toList(), discard.toList()) + .inOrder() + } + + // ---- Connect / disconnect ---------------------------------------------------------------- + + @Test + fun `connect refused returns false and fires no connection callback`() { + transport.connectRefused = true + + assertThat(transport.gatt.connect(ADDRESS)).isFalse() + + // The BLE stack refused to even start — the driver must not be told it connected. + assertThat(listener.events).isEmpty() + } + + @Test + fun `connect without a connected report returns true but fires no callback`() { + transport.reportConnected = false + + // The stack accepted the request; the patch simply never answers — this is the connect-timeout + // path, and it is the absence of the callback that drives it. + assertThat(transport.gatt.connect(ADDRESS)).isTrue() + assertThat(listener.events).isEmpty() + } + + @Test + fun `disconnect before connect fires nothing`() { + transport.gatt.disconnect() + + assertThat(listener.events).isEmpty() + } + + @Test + fun `disconnect fires a single disconnected callback and is idempotent`() { + transport.gatt.connect(ADDRESS) + listener.clear() + + transport.gatt.disconnect() + transport.gatt.disconnect() + + // A second disconnect must stay silent — a duplicate lost-connection event would tear the + // session down twice. + assertThat(listener.connectionStates).containsExactly(false) + } + + // ---- Bonding ----------------------------------------------------------------------------- + + @Test + fun `adapter reports the emulated bond state`() { + assertThat(transport.adapter.isDeviceBonded(ADDRESS)).isTrue() + + transport.bonded = false + + assertThat(transport.adapter.isDeviceBonded(ADDRESS)).isFalse() + } + + @Test + fun `createBond counts the call and bonds the patch`() { + transport.bonded = false + + assertThat(transport.adapter.createBond(ADDRESS)).isTrue() + + assertThat(transport.bonded).isTrue() + assertThat(transport.adapter.isDeviceBonded(ADDRESS)).isTrue() + assertThat(transport.bondCalls).isEqualTo(1) + + transport.adapter.createBond(ADDRESS) + + assertThat(transport.bondCalls).isEqualTo(2) + } + + @Test + fun `removeBond unbonds the patch`() { + transport.bonded = true + + transport.adapter.removeBond(ADDRESS) + + assertThat(transport.bonded).isFalse() + assertThat(transport.adapter.isDeviceBonded(ADDRESS)).isFalse() + } + + // ---- Scanner ----------------------------------------------------------------------------- + + @Test + fun `a discovery scan asks the serial provider and reports the patch mac`() { + val discovering = CarelevoEmulatorBleTransport(emulator, serialNumberProvider = { PROVIDED_SERIAL }) + + discovering.scanner.startScan() + val device = runBlocking { discovering.scanner.scannedDevices.first() } + + // No address filter means the pairing wizard is hunting for a fresh patch — that is where the + // serial comes from, so the provider decides what the patch now calls itself. + assertThat(discovering.pumpState.serialNumber).isEqualTo(PROVIDED_SERIAL) + assertThat(device.address).isEqualTo(DEFAULT_MAC) + assertThat(device.name).isEqualTo("CareLevo-$PROVIDED_SERIAL") + } + + @Test + fun `a filtered scan reports the scan address and leaves the serial alone`() { + var providerCalls = 0 + val reconnecting = CarelevoEmulatorBleTransport( + emulator, + serialNumberProvider = { + providerCalls++ + PROVIDED_SERIAL + } + ) + reconnecting.scanAddress = FILTER_ADDRESS + + reconnecting.scanner.startScan() + val device = runBlocking { reconnecting.scanner.scannedDevices.first() } + + // A filtered scan is a reconnect to a patch already known — re-deriving its serial would + // overwrite the identity the driver is reconnecting to. + assertThat(providerCalls).isEqualTo(0) + assertThat(reconnecting.pumpState.serialNumber).isEqualTo(DEFAULT_SERIAL) + assertThat(device.address).isEqualTo(FILTER_ADDRESS) + } + + // ---- Adapter identity -------------------------------------------------------------------- + + @Test + fun `getDeviceName follows the patch serial`() { + assertThat(transport.adapter.getDeviceName(ADDRESS)).isEqualTo("CareLevo-$DEFAULT_SERIAL") + + state.serialNumber = PROVIDED_SERIAL + + // The name is derived, not cached — a re-serialled patch renames itself. + assertThat(transport.adapter.getDeviceName(ADDRESS)).isEqualTo("CareLevo-$PROVIDED_SERIAL") + } + + // ---- Listener lifecycle ------------------------------------------------------------------ + + @Test + fun `setListener null detaches and a later write delivers nothing without throwing`() { + transport.gatt.connect(ADDRESS) + listener.clear() + + transport.setListener(null) + transport.gatt.writeCharacteristic(PatchInfoCommand().encode()) + + assertThat(listener.events).isEmpty() + // The frame still reached the emulator — only the delivery path is detached. + assertThat(transport.writes).hasSize(1) + } + + @Test + fun `re-registering a listener routes callbacks to the new listener only`() { + val firstSession = RecordingListener() + val secondSession = RecordingListener() + + transport.setListener(firstSession) + transport.gatt.connect(ADDRESS) + transport.gatt.close() + firstSession.clear() + + transport.setListener(secondSession) + transport.gatt.connect(ADDRESS) + transport.gatt.writeCharacteristic(PatchInfoCommand().encode()) + + // The driver opens a session per operation, so this cycle is the norm, not an edge case: a + // frame leaking to the closed session's listener would resolve a waiter that is already gone. + assertThat(secondSession.connectionStates).containsExactly(true) + assertThat(secondSession.notifications.map { it[0] }) + .containsExactly(PatchInfoCommand.RPT1_OPCODE, PatchInfoCommand.RPT2_OPCODE) + .inOrder() + assertThat(firstSession.events).isEmpty() + } + + // ---- Pairing state ----------------------------------------------------------------------- + + @Test + fun `pairing state round-trips through updatePairingState`() { + assertThat(transport.pairingState.value).isEqualTo(PairingState()) + assertThat(transport.pairingState.value.step).isEqualTo(PairingStep.IDLE) + + val pairing = PairingState(step = PairingStep.WAITING_FOR_PAIRING_CONFIRM, errorMessage = "confirm on patch") + transport.updatePairingState(pairing) + + assertThat(transport.pairingState.value).isEqualTo(pairing) + } + + // ---- State delegation -------------------------------------------------------------------- + + @Test + fun `pumpState is the emulator state instance`() { + // Not a copy: a test mutates `transport.pumpState` and the emulator must answer from it. + assertThat(transport.pumpState).isSameInstanceAs(emulator.state) + assertThat(transport.pumpState).isSameInstanceAs(state) + } + + // ---- Helpers ----------------------------------------------------------------------------- + + /** Records every callback as a flat, ordered event log — the assertion surface for fan-out order. */ + private class RecordingListener : BleTransportListener { + + val events: MutableList = mutableListOf() + val connectionStates: MutableList = mutableListOf() + val notifications: MutableList = mutableListOf() + + override fun onConnectionStateChanged(connected: Boolean) { + connectionStates += connected + events += if (connected) CONNECTED else DISCONNECTED + } + + override fun onServicesDiscovered(success: Boolean) { + events += if (success) SERVICES_OK else SERVICES_FAILED + } + + override fun onDescriptorWritten() { + events += DESCRIPTOR + } + + override fun onCharacteristicChanged(data: ByteArray) { + notifications += data.copyOf() + events += CHANGED + } + + override fun onCharacteristicWritten() { + events += WRITTEN + } + + fun clear() { + events.clear() + connectionStates.clear() + notifications.clear() + } + } + + private companion object { + + /** [CarelevoPumpState.macAddress] rendered the way the scanner reports it. */ + const val DEFAULT_MAC = "94:B2:16:1D:2F:6D" + + /** The address the driver hands to `gatt`/`adapter` calls — the emulator ignores it. */ + const val ADDRESS = DEFAULT_MAC + const val FILTER_ADDRESS = "AA:BB:CC:DD:EE:FF" + + const val DEFAULT_SERIAL = "CL24000000001" + const val PROVIDED_SERIAL = "CL24000000042" + + const val CONNECTED = "connected" + const val DISCONNECTED = "disconnected" + const val SERVICES_OK = "services:true" + const val SERVICES_FAILED = "services:false" + const val DESCRIPTOR = "descriptor" + const val WRITTEN = "written" + const val CHANGED = "changed" + } +} diff --git a/pump/carelevo-emulator/src/test/kotlin/app/aaps/pump/carelevo/emulator/CarelevoPumpEmulatorTest.kt b/pump/carelevo-emulator/src/test/kotlin/app/aaps/pump/carelevo/emulator/CarelevoPumpEmulatorTest.kt new file mode 100644 index 000000000000..71ad03d61b83 --- /dev/null +++ b/pump/carelevo-emulator/src/test/kotlin/app/aaps/pump/carelevo/emulator/CarelevoPumpEmulatorTest.kt @@ -0,0 +1,829 @@ +package app.aaps.pump.carelevo.emulator + +import app.aaps.pump.carelevo.ble.commands.AdditionalPrimingCommand +import app.aaps.pump.carelevo.ble.commands.AlarmClearCommand +import app.aaps.pump.carelevo.ble.commands.AlertAlarmSetCommand +import app.aaps.pump.carelevo.ble.commands.AppAuthCommand +import app.aaps.pump.carelevo.ble.commands.BasalProgramCommand +import app.aaps.pump.carelevo.ble.commands.BolusCancelCommand +import app.aaps.pump.carelevo.ble.commands.BuzzModeCommand +import app.aaps.pump.carelevo.ble.commands.ExtendBolusCancelCommand +import app.aaps.pump.carelevo.ble.commands.ExtendBolusCommand +import app.aaps.pump.carelevo.ble.commands.ImmediateBolusCommand +import app.aaps.pump.carelevo.ble.commands.InfusionInfoCommand +import app.aaps.pump.carelevo.ble.commands.InfusionThresholdCommand +import app.aaps.pump.carelevo.ble.commands.MacAddressCommand +import app.aaps.pump.carelevo.ble.commands.MacAddressResponse +import app.aaps.pump.carelevo.ble.commands.NeedleAckCommand +import app.aaps.pump.carelevo.ble.commands.NeedleStatusCommand +import app.aaps.pump.carelevo.ble.commands.NoticeThresholdCommand +import app.aaps.pump.carelevo.ble.commands.PatchDiscardCommand +import app.aaps.pump.carelevo.ble.commands.PatchInfoCommand +import app.aaps.pump.carelevo.ble.commands.PumpResumeCommand +import app.aaps.pump.carelevo.ble.commands.PumpStopCommand +import app.aaps.pump.carelevo.ble.commands.SafetyCheckCommand +import app.aaps.pump.carelevo.ble.commands.SetTimeCommand +import app.aaps.pump.carelevo.ble.commands.SetTimeForPatchInfoCommand +import app.aaps.pump.carelevo.ble.commands.TempBasalCancelCommand +import app.aaps.pump.carelevo.ble.commands.TempBasalCommand +import app.aaps.pump.carelevo.ble.commands.ThresholdSetupCommand +import com.google.common.truth.Truth.assertThat +import org.joda.time.DateTime +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.experimental.xor + +/** + * Wire-compatibility tests for [CarelevoPumpEmulator], driven end-to-end through the **production** + * codec: every request is built with the real command's `encode()` and every response is parsed with + * the real command's `decode(...)`. Nothing here hand-asserts an invented byte array, so a passing + * test means the emulator is byte-for-byte compatible with what `BleClientImpl` actually writes and + * reads — not merely self-consistent. + * + * The three [app.aaps.pump.carelevo.ble.BleClient] shapes are exercised the way the client drives them: + * - [app.aaps.pump.carelevo.ble.BleCommand] → `decode(frames.single())` + * - [app.aaps.pump.carelevo.ble.BleMultiCommand] → `decode(frames.associateBy { it[0] })` (keyed by opcode) + * - [app.aaps.pump.carelevo.ble.BleStreamCommand]→ decode each frame, terminate on `isTerminal(decoded)` + * + * Pure JVM: the emulator takes a nullable logger and touches no Android API, so no Robolectric. + */ +class CarelevoPumpEmulatorTest { + + private lateinit var state: CarelevoPumpState + private lateinit var emulator: CarelevoPumpEmulator + + @BeforeEach + fun setUp() { + state = CarelevoPumpState() + emulator = CarelevoPumpEmulator(state) + } + + // ---- Pairing --------------------------------------------------------------------------------- + + @Test + fun `mac address round-trip decodes the patch address and checksum`() { + val cmd = MacAddressCommand(MAC_KEY.toByte()) + + val frames = emulator.handle(cmd.encode()) + val decoded = cmd.decode(frames.single()) + + assertThat(frames.single()[0]).isEqualTo(MacAddressCommand.RESPONSE_OPCODE) + assertThat(decoded.macAddress).isEqualTo("94B2161D2F6D") + assertThat(decoded.checkSum).isEqualTo("5A") + // The key must be retained — 0x4B verifies the app's fold against it. + assertThat(state.lastMacKey).isEqualTo(MAC_KEY) + } + + @Test + fun `app auth accepts the checksum fold the session computes`() { + val macResponse = readMac(MAC_KEY) + val cmd = AppAuthCommand(sessionFold(macResponse, MAC_KEY)) + + val frames = emulator.handle(cmd.encode()) + val decoded = cmd.decode(frames.single()) + + assertThat(decoded.resultCode).isEqualTo(0) + } + + @Test + fun `app auth rejects a wrong checksum`() { + val macResponse = readMac(MAC_KEY) + // One bit off the fold the patch expects — a real patch rejects this, so the emulator must too. + val cmd = AppAuthCommand((sessionFold(macResponse, MAC_KEY) + 1) and 0xFF) + + val frames = emulator.handle(cmd.encode()) + val decoded = cmd.decode(frames.single()) + + assertThat(decoded.resultCode).isNotEqualTo(0) + } + + @Test + fun `app auth before the mac read fails`() { + // No 0x3B first, so the patch has no key to verify against. + val cmd = AppAuthCommand(0x42) + + val decoded = cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(decoded.resultCode).isNotEqualTo(0) + } + + @Test + fun `app auth answers on 0xBB not 0x4B 0xBA or 0xAB`() { + val macResponse = readMac(MAC_KEY) + val cmd = AppAuthCommand(sessionFold(macResponse, MAC_KEY)) + + val opcode = emulator.handle(cmd.encode()).single()[0] + + assertThat(opcode).isEqualTo(0xBB.toByte()) + assertThat(opcode).isNotEqualTo(0x4B.toByte()) + assertThat(opcode).isNotEqualTo(0xBA.toByte()) + assertThat(opcode).isNotEqualTo(0xAB.toByte()) + // ...and the production decoder agrees, since it requires 0xBB at byte 0. + assertThat(cmd.decode(emulator.handle(cmd.encode()).single()).resultCode).isEqualTo(0) + } + + @Test + fun `app auth result code override wins over a correct fold`() { + val macResponse = readMac(MAC_KEY) + state.resultCodeOverrides[CarelevoPumpEmulator.OP_APP_AUTH] = 2 + val cmd = AppAuthCommand(sessionFold(macResponse, MAC_KEY)) + + val decoded = cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(decoded.resultCode).isEqualTo(2) + } + + // ---- The swapped needle pair ----------------------------------------------------------------- + + @Test + fun `needle ack 0x19 answers on 0x7A not 0x79`() { + val cmd = NeedleAckCommand(isSuccess = true) + + val frames = emulator.handle(cmd.encode()) + + // A "+0x60" emulator would answer 0x79 here and deadlock the client. + assertThat(frames.single()[0]).isEqualTo(0x7A.toByte()) + assertThat(frames.single()[0]).isNotEqualTo(0x79.toByte()) + assertThat(cmd.decode(frames.single()).resultCode).isEqualTo(0) + assertThat(state.needleInserted).isTrue() + } + + @Test + fun `needle status 0x1A answers on 0x79 not 0x7A`() { + val cmd = NeedleStatusCommand() + + val frames = emulator.handle(cmd.encode()) + + assertThat(frames.single()[0]).isEqualTo(0x79.toByte()) + assertThat(frames.single()[0]).isNotEqualTo(0x7A.toByte()) + assertThat(cmd.decode(frames.single()).resultCode).isEqualTo(0) + } + + @Test + fun `needle ack with the failure flag leaves the needle uninserted`() { + val cmd = NeedleAckCommand(isSuccess = false) + + cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(state.needleInserted).isFalse() + } + + // ---- Patch info ------------------------------------------------------------------------------ + + @Test + fun `patch info answers with exactly the two report frames 0x93 and 0x94`() { + val cmd = PatchInfoCommand() + + val frames = emulator.handle(cmd.encode()) + + assertThat(frames.map { it[0] }).containsExactly(0x93.toByte(), 0x94.toByte()) + assertThat(frames.map { it[0] }.toSet()).isEqualTo(cmd.expectedResponseOpcodes) + } + + @Test + fun `patch info round-trip decodes serial firmware and the decimal-concat model name`() { + val cmd = PatchInfoCommand() + + val decoded = cmd.decode(emulator.handle(cmd.encode()).associateBy { it[0] }) + + assertThat(decoded.serialResultCode).isEqualTo(0) + assertThat(decoded.serialNumber).isEqualTo("CL24000000001") + assertThat(decoded.detailResultCode).isEqualTo(0) + assertThat(decoded.firmwareVersion).isEqualTo("1.10") + // The quirk: model bytes are concatenated DECIMAL values, not ASCII — [1,2,0,0,0] -> "12000". + assertThat(decoded.modelName).isEqualTo("12000") + } + + @Test + fun `patch info model name follows the state model bytes`() { + state.modelBytes = byteArrayOf(2, 15, 0, 3, 0) + val cmd = PatchInfoCommand() + + val decoded = cmd.decode(emulator.handle(cmd.encode()).associateBy { it[0] }) + + assertThat(decoded.modelName).isEqualTo("215030") + } + + // ---- SetTime split --------------------------------------------------------------------------- + + @Test + fun `set time with subId 0 answers with the patch info pair`() { + val cmd = SetTimeForPatchInfoCommand(subId = 0, volume = 200, aidMode = 0, dateTime = DATE_TIME) + + val frames = emulator.handle(cmd.encode()) + val decoded = cmd.decode(frames.associateBy { it[0] }) + + // The activation path waits for 0x93+0x94; a 0x71 here would hang it. + assertThat(frames.map { it[0] }).containsExactly(0x93.toByte(), 0x94.toByte()) + assertThat(decoded.serialNumber).isEqualTo("CL24000000001") + } + + @Test + fun `set time with a non-zero subId answers on 0x71`() { + val cmd = SetTimeCommand(subId = 1, volume = 200, aidMode = 0, dateTime = DATE_TIME) + + val frames = emulator.handle(cmd.encode()) + val decoded = cmd.decode(frames.single()) + + assertThat(frames.single()[0]).isEqualTo(SetTimeCommand.RESPONSE_OPCODE) + assertThat(decoded.resultCode).isEqualTo(0) + } + + @Test + fun `set time frames differ only in the subId byte`() { + val forPatchInfo = SetTimeForPatchInfoCommand(subId = 0, volume = 200, aidMode = 0, dateTime = DATE_TIME).encode() + val plain = SetTimeCommand(subId = 1, volume = 200, aidMode = 0, dateTime = DATE_TIME).encode() + + // Byte-identical 11-byte frames on one opcode — only byte 1 tells the two commands apart, + // which is exactly why the emulator has to branch on it. + assertThat(forPatchInfo).hasLength(CarelevoPumpEmulator.SET_TIME_LENGTH) + assertThat(plain).hasLength(CarelevoPumpEmulator.SET_TIME_LENGTH) + assertThat(forPatchInfo[0]).isEqualTo(plain[0]) + assertThat(forPatchInfo[1]).isNotEqualTo(plain[1]) + assertThat(forPatchInfo.copyOfRange(2, forPatchInfo.size)) + .isEqualTo(plain.copyOfRange(2, plain.size)) + } + + @Test + fun `set time records the subId and resets the running minutes`() { + state.runningMinutes = 500 + val cmd = SetTimeCommand(subId = 1, volume = 150, aidMode = 0, dateTime = DATE_TIME) + + cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(state.subId).isEqualTo(1) + assertThat(state.runningMinutes).isEqualTo(0) + } + + // ---- Safety check stream --------------------------------------------------------------------- + + @Test + fun `safety check streams progress frames then a terminal frame`() { + state.safetyCheckProgressFrames = 3 + val cmd = SafetyCheckCommand() + + val decodedFrames = emulator.handle(cmd.encode()).map { cmd.decode(it) } + + assertThat(decodedFrames).hasSize(4) + assertThat(decodedFrames.dropLast(1).map { cmd.isTerminal(it) }).containsExactly(false, false, false) + assertThat(decodedFrames.last().resultCode).isEqualTo(SafetyCheckCommand.RESULT_SUCCESS) + assertThat(cmd.isTerminal(decodedFrames.last())).isTrue() + } + + @Test + fun `safety check progress frames carry a progress result code`() { + val cmd = SafetyCheckCommand() + + val decodedFrames = emulator.handle(cmd.encode()).map { cmd.decode(it) } + + // Progress is signalled purely by the result byte being REP_REQUEST/REP_REQUEST1. + assertThat(decodedFrames.dropLast(1).map { it.resultCode }) + .containsExactly(SafetyCheckCommand.REP_REQUEST, SafetyCheckCommand.REP_REQUEST) + } + + @Test + fun `safety check never emits a 5-byte frame`() { + state.safetyCheckProgressFrames = 4 + val cmd = SafetyCheckCommand() + + val frames = emulator.handle(cmd.encode()) + + // SafetyCheckCommand.decode reads index 5 whenever size > 4, so a 5-byte frame would throw + // ArrayIndexOutOfBounds in the real driver instead of failing cleanly. + assertThat(frames.map { it.size }).doesNotContain(5) + assertThat(frames.map { it.size }.all { it == 4 || it >= 6 }).isTrue() + assertThat(frames.map { it[0] }.toSet()).containsExactly(SafetyCheckCommand.RESPONSE_OPCODE) + } + + @Test + fun `safety check frames decode the priming volume and duration`() { + state.safetyCheckVolume = 250 + state.safetyCheckDurationSeconds = 185 + val cmd = SafetyCheckCommand() + + val decoded = cmd.decode(emulator.handle(cmd.encode()).last()) + + assertThat(decoded.insulinVolume).isEqualTo(250) + assertThat(decoded.durationSeconds).isEqualTo(185) + } + + @Test + fun `safety check success moves the patch to RUNNING`() { + state.pumpStateRaw = CarelevoPumpEmulator.PUMP_STATE_READY + val cmd = SafetyCheckCommand() + + cmd.decode(emulator.handle(cmd.encode()).last()) + + assertThat(state.pumpStateRaw).isEqualTo(CarelevoPumpEmulator.PUMP_STATE_RUNNING) + } + + @Test + fun `safety check error terminates the stream and leaves the pump state alone`() { + state.pumpStateRaw = CarelevoPumpEmulator.PUMP_STATE_READY + state.safetyCheckResult = 11 + val cmd = SafetyCheckCommand() + + val decoded = cmd.decode(emulator.handle(cmd.encode()).last()) + + // An error code is terminal too — the stream must complete rather than time out. + assertThat(cmd.isTerminal(decoded)).isTrue() + assertThat(decoded.resultCode).isEqualTo(11) + assertThat(state.pumpStateRaw).isEqualTo(CarelevoPumpEmulator.PUMP_STATE_READY) + } + + @Test + fun `safety check with no progress frames emits only the terminal frame`() { + state.safetyCheckProgressFrames = 0 + val cmd = SafetyCheckCommand() + + val frames = emulator.handle(cmd.encode()) + + assertThat(frames).hasSize(1) + assertThat(cmd.isTerminal(cmd.decode(frames.single()))).isTrue() + } + + // ---- Immediate bolus ------------------------------------------------------------------------- + + @Test + fun `immediate bolus round-trip echoes the action id and decodes the remaining reservoir`() { + val cmd = ImmediateBolusCommand(actionId = 42, volume = 2.5) + + val frames = emulator.handle(cmd.encode()) + val decoded = cmd.decode(frames.single()) + + assertThat(frames.single()[0]).isEqualTo(ImmediateBolusCommand.RESPONSE_OPCODE) + // Byte 1 is the correlation byte — the client silently drops a frame that does not match. + assertThat(frames.single()[1]).isEqualTo(cmd.correlationByte) + assertThat(decoded.actionId).isEqualTo(42) + assertThat(decoded.resultCode).isEqualTo(0) + assertThat(decoded.expectedCompletionSeconds).isEqualTo(50) + assertThat(decoded.remainingReservoirUnits).isWithin(TOLERANCE).of(197.5) + } + + @Test + fun `immediate bolus echoes an action id above 127 unsigned`() { + val cmd = ImmediateBolusCommand(actionId = 200, volume = 1.0) + + val decoded = cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(decoded.actionId).isEqualTo(200) + assertThat(state.activeBolusActionId).isEqualTo(200) + } + + @Test + fun `immediate bolus decrements the reservoir and adds to the bolus total`() { + state.insulinRemaining = 100.0 + state.infusedTotalBolus = 3.0 + val cmd = ImmediateBolusCommand(actionId = 7, volume = 4.25) + + cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(state.insulinRemaining).isWithin(TOLERANCE).of(95.75) + assertThat(state.infusedTotalBolus).isWithin(TOLERANCE).of(7.25) + assertThat(state.modeRaw).isEqualTo(CarelevoPumpEmulator.MODE_IMME_BOLUS) + // The emulated bolus lands immediately, so the whole dose is already infused — a later cancel + // reports this, rather than claiming 0 U for insulin the totals above have already counted. + assertThat(state.bolusInfusedAmount).isWithin(TOLERANCE).of(4.25) + } + + @Test + fun `immediate bolus failure keeps the reservoir and still echoes the action id`() { + state.resultCodeOverrides[CarelevoPumpEmulator.OP_IMMEDIATE_BOLUS] = 6 + val cmd = ImmediateBolusCommand(actionId = 99, volume = 3.0) + + val frames = emulator.handle(cmd.encode()) + val decoded = cmd.decode(frames.single()) + + // Result code lives at index 2 — index 1 is the echoed actionId, not the result. + assertThat(frames.single()[2]).isEqualTo(6.toByte()) + assertThat(decoded.resultCode).isEqualTo(6) + assertThat(decoded.actionId).isEqualTo(99) + assertThat(state.insulinRemaining).isWithin(TOLERANCE).of(200.0) + assertThat(state.activeBolusActionId).isNull() + } + + // ---- Infusion info --------------------------------------------------------------------------- + + @Test + fun `infusion info frame is 20 bytes and echoes the inquiry type`() { + val cmd = InfusionInfoCommand(inquiryType = 1) + + val frames = emulator.handle(cmd.encode()) + val decoded = cmd.decode(frames.single()) + + // The decoder reads index 19, so anything shorter than 20 fails the require(). + assertThat(frames.single().size).isAtLeast(20) + assertThat(frames.single()[0]).isEqualTo(InfusionInfoCommand.RESPONSE_OPCODE) + assertThat(decoded.subId).isEqualTo(1) + } + + @Test + fun `infusion info round-trip decodes every field`() { + state.runningMinutes = 125 + state.insulinRemaining = 187.65 + state.infusedTotalBasal = 12.34 + state.infusedTotalBolus = 5.5 + state.pumpStateRaw = CarelevoPumpEmulator.PUMP_STATE_RUNNING + state.modeRaw = CarelevoPumpEmulator.MODE_BASAL + val cmd = InfusionInfoCommand() + + val decoded = cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(decoded.subId).isEqualTo(0) + assertThat(decoded.runningMinutes).isEqualTo(125) + assertThat(decoded.insulinRemaining).isWithin(TOLERANCE).of(187.65) + assertThat(decoded.infusedTotalBasalAmount).isWithin(TOLERANCE).of(12.34) + assertThat(decoded.infusedTotalBolusAmount).isWithin(TOLERANCE).of(5.5) + assertThat(decoded.pumpStateRaw).isEqualTo(2) + assertThat(decoded.modeRaw).isEqualTo(1) + assertThat(decoded.currentInfusedProgramVolume).isWithin(TOLERANCE).of(5.5) + // realInfusedTime is seconds: 125 min -> 7500 s. + assertThat(decoded.realInfusedTime).isEqualTo(7500) + } + + // ---- Basal program --------------------------------------------------------------------------- + + @Test + fun `basal program acks each sequence individually and commits only on seq 2`() { + val seq0 = BasalProgramCommand(isUpdate = false, seqNo = 0, segmentSpeeds = listOf(0.5, 0.75)) + val seq1 = BasalProgramCommand(isUpdate = false, seqNo = 1, segmentSpeeds = listOf(1.25, 1.5)) + val seq2 = BasalProgramCommand(isUpdate = false, seqNo = 2, segmentSpeeds = listOf(2.0, 2.5)) + + assertThat(seq0.decode(emulator.handle(seq0.encode()).single()).resultCode).isEqualTo(0) + assertThat(state.basalProgramCommitted).isFalse() + + assertThat(seq1.decode(emulator.handle(seq1.encode()).single()).resultCode).isEqualTo(0) + assertThat(state.basalProgramCommitted).isFalse() + + assertThat(seq2.decode(emulator.handle(seq2.encode()).single()).resultCode).isEqualTo(0) + assertThat(state.basalProgramCommitted).isTrue() + } + + @Test + fun `basal program returns the segments in sequence order`() { + listOf( + BasalProgramCommand(isUpdate = false, seqNo = 2, segmentSpeeds = listOf(2.0, 2.5)), + BasalProgramCommand(isUpdate = false, seqNo = 0, segmentSpeeds = listOf(0.5, 0.75)), + BasalProgramCommand(isUpdate = false, seqNo = 1, segmentSpeeds = listOf(1.25, 1.5)) + ).forEach { cmd -> cmd.decode(emulator.handle(cmd.encode()).single()) } + + // Written out of order; the program still reads back in segment order. + assertThat(state.basalProgram).containsExactly(0.5, 0.75, 1.25, 1.5, 2.0, 2.5).inOrder() + } + + @Test + fun `basal program set uses 0x13 to 0x73 and update uses 0x21 to 0x81`() { + val set = BasalProgramCommand(isUpdate = false, seqNo = 0, segmentSpeeds = listOf(1.0)) + val update = BasalProgramCommand(isUpdate = true, seqNo = 0, segmentSpeeds = listOf(1.0)) + + val setFrame = emulator.handle(set.encode()).single() + val updateFrame = emulator.handle(update.encode()).single() + + assertThat(set.encode()[0]).isEqualTo(BasalProgramCommand.SET_REQUEST_OPCODE) + assertThat(setFrame[0]).isEqualTo(BasalProgramCommand.SET_RESPONSE_OPCODE) + assertThat(update.encode()[0]).isEqualTo(BasalProgramCommand.UPDATE_REQUEST_OPCODE) + assertThat(updateFrame[0]).isEqualTo(BasalProgramCommand.UPDATE_RESPONSE_OPCODE) + assertThat(set.decode(setFrame).resultCode).isEqualTo(0) + assertThat(update.decode(updateFrame).resultCode).isEqualTo(0) + } + + @Test + fun `basal program failure does not bank the segments`() { + state.resultCodeOverrides[BasalProgramCommand.SET_REQUEST_OPCODE] = 9 + val cmd = BasalProgramCommand(isUpdate = false, seqNo = 2, segmentSpeeds = listOf(1.0, 2.0)) + + val decoded = cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(decoded.resultCode).isEqualTo(9) + assertThat(state.basalProgramCommitted).isFalse() + assertThat(state.basalProgram).isEmpty() + } + + // ---- Index-shifted result codes --------------------------------------------------------------- + + @Test + fun `infusion threshold carries its result at index 2 behind the echoed type`() { + state.resultCodeOverrides[CarelevoPumpEmulator.OP_INFUSION_THRESHOLD] = 3 + val cmd = InfusionThresholdCommand(isMaxVolume = true, value = 12.5) + + val frames = emulator.handle(cmd.encode()) + val decoded = cmd.decode(frames.single()) + + // Byte 1 echoes the type flag, so the result sits at index 2 — not the usual index 1. + assertThat(frames.single()[1]).isEqualTo(0x01.toByte()) + assertThat(frames.single()[2]).isEqualTo(3.toByte()) + assertThat(decoded.type).isEqualTo(1) + assertThat(decoded.resultCode).isEqualTo(3) + } + + @Test + fun `infusion threshold max volume round-trip stores the max bolus dose`() { + val cmd = InfusionThresholdCommand(isMaxVolume = true, value = 12.5) + + val decoded = cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(decoded.type).isEqualTo(1) + assertThat(decoded.resultCode).isEqualTo(0) + assertThat(state.maxBolusDose).isWithin(TOLERANCE).of(12.5) + } + + @Test + fun `infusion threshold max speed round-trip stores the max basal speed`() { + val cmd = InfusionThresholdCommand(isMaxVolume = false, value = 3.75) + + val decoded = cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(decoded.type).isEqualTo(0) + assertThat(state.maxBasalSpeed).isWithin(TOLERANCE).of(3.75) + } + + @Test + fun `alarm clear carries its result at index 3 behind the echoed type and cause`() { + state.resultCodeOverrides[CarelevoPumpEmulator.OP_ALARM_CLEAR] = 4 + val cmd = AlarmClearCommand(alarmType = 5, cause = 3) + + val frames = emulator.handle(cmd.encode()) + val decoded = cmd.decode(frames.single()) + + assertThat(frames.single()[0]).isEqualTo(AlarmClearCommand.RESPONSE_OPCODE) + assertThat(frames.single()[3]).isEqualTo(4.toByte()) + assertThat(decoded.subId).isEqualTo(5) + assertThat(decoded.cause).isEqualTo(3) + assertThat(decoded.resultCode).isEqualTo(4) + } + + @Test + fun `notice threshold echoes the type with no result code on the wire`() { + val lowInsulin = NoticeThresholdCommand(thresholdType = NoticeThresholdCommand.TYPE_LOW_INSULIN, value = 35) + val expiry = NoticeThresholdCommand(thresholdType = NoticeThresholdCommand.TYPE_EXPIRY, value = 48) + + val lowFrame = emulator.handle(lowInsulin.encode()).single() + val expiryFrame = emulator.handle(expiry.encode()).single() + + // The 0x75 frame is [opcode][type] — arrival IS the success signal; resultCode is fabricated. + assertThat(lowFrame).hasLength(2) + assertThat(lowFrame[1]).isEqualTo(0x00.toByte()) + assertThat(expiryFrame[1]).isEqualTo(0x01.toByte()) + assertThat(lowInsulin.decode(lowFrame).thresholdType).isEqualTo(NoticeThresholdCommand.TYPE_LOW_INSULIN) + assertThat(lowInsulin.decode(lowFrame).resultCode).isEqualTo(0) + assertThat(expiry.decode(expiryFrame).thresholdType).isEqualTo(NoticeThresholdCommand.TYPE_EXPIRY) + assertThat(state.lowInsulinNotice).isEqualTo(35) + assertThat(state.expiryNotice).isEqualTo(48) + } + + // ---- Remaining opcodes ------------------------------------------------------------------------ + + @Test + fun `buzz mode round-trip stores the buzzer flag`() { + val on = BuzzModeCommand(use = true) + assertThat(on.decode(emulator.handle(on.encode()).single()).resultCode).isEqualTo(0) + assertThat(state.buzzUse).isTrue() + + val off = BuzzModeCommand(use = false) + assertThat(off.decode(emulator.handle(off.encode()).single()).resultCode).isEqualTo(0) + assertThat(state.buzzUse).isFalse() + } + + @Test + fun `threshold setup round-trip stores the whole activation bundle`() { + val cmd = ThresholdSetupCommand( + insulinRemainsThreshold = 30, + expiryThreshold = 48, + maxBasalSpeed = 10.5, + maxBolusDose = 20.25, + buzzUse = true + ) + + val decoded = cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(decoded.resultCode).isEqualTo(0) + assertThat(state.insulinRemainsThreshold).isEqualTo(30) + assertThat(state.expiryThreshold).isEqualTo(48) + assertThat(state.maxBasalSpeed).isWithin(TOLERANCE).of(10.5) + assertThat(state.maxBolusDose).isWithin(TOLERANCE).of(20.25) + assertThat(state.buzzUse).isTrue() + } + + @Test + fun `additional priming round-trip counts the priming pulse`() { + val cmd = AdditionalPrimingCommand() + + assertThat(cmd.decode(emulator.handle(cmd.encode()).single()).resultCode).isEqualTo(0) + cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(state.primingCount).isEqualTo(2) + } + + @Test + fun `temp basal by unit round-trip starts the temp basal`() { + val cmd = TempBasalCommand.byUnit(infusionUnit = 1.5, infusionHour = 2, infusionMin = 30) + + val decoded = cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(cmd.encode()).hasLength(6) // BY_UNIT carries the trailing 0x00 + assertThat(decoded.resultCode).isEqualTo(0) + assertThat(state.tempBasalRunning).isTrue() + assertThat(state.modeRaw).isEqualTo(CarelevoPumpEmulator.MODE_TEMP_BASAL) + } + + @Test + fun `temp basal by percent round-trip uses the same opcode pair`() { + val cmd = TempBasalCommand.byPercent(infusionPercent = 150, infusionHour = 1, infusionMin = 0) + + val frames = emulator.handle(cmd.encode()) + val decoded = cmd.decode(frames.single()) + + assertThat(cmd.encode()).hasLength(5) // BY_PERCENT has no trailing byte — the asymmetry + assertThat(frames.single()[0]).isEqualTo(TempBasalCommand.RESPONSE_OPCODE) + assertThat(decoded.resultCode).isEqualTo(0) + assertThat(state.tempBasalRunning).isTrue() + } + + @Test + fun `temp basal cancel round-trip stops the temp basal`() { + state.tempBasalRunning = true + state.modeRaw = CarelevoPumpEmulator.MODE_TEMP_BASAL + val cmd = TempBasalCancelCommand() + + val decoded = cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(decoded.resultCode).isEqualTo(0) + assertThat(state.tempBasalRunning).isFalse() + assertThat(state.modeRaw).isEqualTo(CarelevoPumpEmulator.MODE_BASAL) + } + + @Test + fun `extend bolus round-trip starts the extended bolus`() { + val cmd = ExtendBolusCommand(immediateDose = 1.0, extendedSpeed = 0.5, hour = 1, min = 30) + + val decoded = cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(decoded.resultCode).isEqualTo(0) + // The decoder recombines bytes 2..3 as `[2] * 60 + [3]` seconds, so a 1h30m request must come + // back as 5400 s — encoded [90, 0], with the minutes in byte 2 rather than split again by 60. + assertThat(decoded.expectedTimeSeconds).isEqualTo(5400) + assertThat(state.extendedBolusRunning).isTrue() + assertThat(state.modeRaw).isEqualTo(CarelevoPumpEmulator.MODE_EXTEND_BOLUS) + } + + @Test + fun `extend bolus cancel round-trip reports the infused amount`() { + state.extendedBolusRunning = true + state.extendedInfusedAmount = 3.25 + val cmd = ExtendBolusCancelCommand() + + val decoded = cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(decoded.resultCode).isEqualTo(0) + assertThat(decoded.infusedAmount).isWithin(TOLERANCE).of(3.25) + assertThat(state.extendedBolusRunning).isFalse() + assertThat(state.modeRaw).isEqualTo(CarelevoPumpEmulator.MODE_BASAL) + } + + @Test + fun `bolus cancel round-trip reports the infused amount and clears the action id`() { + state.activeBolusActionId = 42 + state.bolusInfusedAmount = 1.75 + val cmd = BolusCancelCommand() + + val decoded = cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(decoded.resultCode).isEqualTo(0) + assertThat(decoded.infusedAmount).isWithin(TOLERANCE).of(1.75) + assertThat(state.activeBolusActionId).isNull() + assertThat(state.modeRaw).isEqualTo(CarelevoPumpEmulator.MODE_BASAL) + } + + @Test + fun `pump stop round-trip stops the patch and records the subId`() { + val cmd = PumpStopCommand(durationMinutes = 90, subId = 1) + + val decoded = cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(decoded.resultCode).isEqualTo(0) + assertThat(state.stopped).isTrue() + assertThat(state.subId).isEqualTo(1) + } + + @Test + fun `pump resume round-trip echoes the mode and defaults the optional subId`() { + state.stopped = true + val cmd = PumpResumeCommand(mode = 1, subId = 1) + + val frames = emulator.handle(cmd.encode()) + val decoded = cmd.decode(frames.single()) + + // The 4th subId byte is omitted; the decoder must default it rather than fail. + assertThat(frames.single()).hasLength(3) + assertThat(decoded.resultCode).isEqualTo(0) + assertThat(decoded.mode).isEqualTo(1) + assertThat(decoded.subId).isEqualTo(0) + assertThat(state.stopped).isFalse() + assertThat(state.modeRaw).isEqualTo(CarelevoPumpEmulator.MODE_BASAL) + } + + @Test + fun `patch discard round-trip discards the patch and returns it to READY`() { + val cmd = PatchDiscardCommand() + + val decoded = cmd.decode(emulator.handle(cmd.encode()).single()) + + assertThat(decoded.resultCode).isEqualTo(0) + assertThat(state.discarded).isTrue() + assertThat(state.pumpStateRaw).isEqualTo(CarelevoPumpEmulator.PUMP_STATE_READY) + } + + @Test + fun `alert alarm set round-trip stores the alarm mode`() { + val cmd = AlertAlarmSetCommand(mode = 3) + + val frames = emulator.handle(cmd.encode()) + val decoded = cmd.decode(frames.single()) + + assertThat(frames.single()[0]).isEqualTo(AlertAlarmSetCommand.RESPONSE_OPCODE) + assertThat(decoded.resultCode).isEqualTo(0) + assertThat(state.alertAlarmMode).isEqualTo(3) + } + + // ---- Fault injection -------------------------------------------------------------------------- + + @Test + fun `silent opcodes make the patch stay quiet`() { + state.silentOpcodes += InfusionInfoCommand.REQUEST_OPCODE + val silent = InfusionInfoCommand() + val loud = PatchDiscardCommand() + + assertThat(emulator.handle(silent.encode())).isEmpty() + // ...and only that opcode goes quiet. + assertThat(emulator.handle(loud.encode())).hasSize(1) + } + + @Test + fun `result code overrides come back through the real decoder`() { + state.resultCodeOverrides[CarelevoPumpEmulator.OP_PUMP_STOP] = 12 + state.resultCodeOverrides[CarelevoPumpEmulator.OP_PATCH_DISCARD] = 7 + val stop = PumpStopCommand(durationMinutes = 30, subId = 0) + val discard = PatchDiscardCommand() + + assertThat(stop.decode(emulator.handle(stop.encode()).single()).resultCode).isEqualTo(12) + assertThat(discard.decode(emulator.handle(discard.encode()).single()).resultCode).isEqualTo(7) + // A non-success result must not apply the state mutation. + assertThat(state.stopped).isFalse() + assertThat(state.discarded).isFalse() + } + + @Test + fun `silent opcode wins over a result code override`() { + state.silentOpcodes += CarelevoPumpEmulator.OP_PATCH_DISCARD + state.resultCodeOverrides[CarelevoPumpEmulator.OP_PATCH_DISCARD] = 7 + + assertThat(emulator.handle(PatchDiscardCommand().encode())).isEmpty() + } + + // ---- Malformed input -------------------------------------------------------------------------- + + @Test + fun `empty request is ignored`() { + assertThat(emulator.handle(ByteArray(0))).isEmpty() + } + + @Test + fun `unknown opcode is ignored`() { + assertThat(emulator.handle(byteArrayOf(0x7F, 0x01, 0x02))).isEmpty() + } + + // ---- Helpers ---------------------------------------------------------------------------------- + + /** Runs the real 0x3B round-trip and returns the decoded response. */ + private fun readMac(key: Int): MacAddressResponse { + val cmd = MacAddressCommand(key.toByte()) + return cmd.decode(emulator.handle(cmd.encode()).single()) + } + + /** + * The app-side handshake fold, exactly as `CarelevoBleSession.runPairing` computes it: + * `(macResponse.macAddress + macResponse.checkSum).convertHexToByteArray().checkSumV2(key)`. + * + * `convertHexToByteArray`/`checkSumV2` are `internal` to `:pump:carelevo`, and Kotlin only grants + * internal visibility to a module's own test source set — so this module cannot call them and the + * hex-parse + XOR fold is replicated here instead. Kept deliberately literal (parse the decoded + * hex strings, fold from `key.toByte()`) so it stays a faithful stand-in for the session's code. + */ + private fun sessionFold(macResponse: MacAddressResponse, key: Int): Int = + (macResponse.macAddress + macResponse.checkSum) + .chunked(2) + .map { it.toInt(16).toByte() } + .fold(key.toByte()) { acc, b -> acc xor b } + .toUByte() + .toInt() + + private companion object { + + /** Above 127 on purpose — proves the key survives the Int→Byte→unsigned round-trip. */ + const val MAC_KEY = 0xC3 + + val DATE_TIME: DateTime = DateTime(2026, 7, 16, 14, 30, 45) + + const val TOLERANCE = 1e-9 + } +} diff --git a/pump/carelevo/build.gradle.kts b/pump/carelevo/build.gradle.kts new file mode 100644 index 000000000000..656fdddd19c4 --- /dev/null +++ b/pump/carelevo/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) + id("android-module-dependencies") + id("test-module-dependencies") + id("compose-test-module-dependencies") + id("jacoco-module-dependencies") +} + +android { + namespace = "app.aaps.pump.carelevo" + + buildFeatures { + compose = true + } +} + +dependencies { + implementation(project(":core:data")) + implementation(project(":core:interfaces")) + implementation(project(":core:utils")) + implementation(project(":core:ui")) + implementation(project(":core:keys")) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + + implementation(libs.com.google.guava) + implementation(libs.androidx.lifecycle.process) + implementation(libs.io.reactivex.rxjava3.rxandroid) + implementation(libs.com.polidea.rxandroidble3) + implementation(libs.com.jakewharton.rx3.replaying.share) + implementation(libs.com.google.android.material) + + implementation(libs.com.google.dagger.hilt.android) + implementation(libs.androidx.hilt.navigation.compose) + ksp(libs.com.google.dagger.compiler) + ksp(libs.com.google.dagger.hilt.compiler) + ksp(libs.com.google.dagger.android.processor) +} diff --git a/pump/carelevo/src/main/AndroidManifest.xml b/pump/carelevo/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..b20c45d1cc6a --- /dev/null +++ b/pump/carelevo/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/CarelevoPumpPlugin.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/CarelevoPumpPlugin.kt new file mode 100644 index 000000000000..bc1e9ba34f9c --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/CarelevoPumpPlugin.kt @@ -0,0 +1,762 @@ +package app.aaps.pump.carelevo + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.Context +import android.content.IntentFilter +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ProcessLifecycleOwner +import app.aaps.core.data.plugin.PluginType +import app.aaps.core.data.pump.defs.ManufacturerType +import app.aaps.core.data.pump.defs.PumpDescription +import app.aaps.core.data.pump.defs.PumpType +import app.aaps.core.data.pump.defs.TimeChangeType +import app.aaps.core.interfaces.configuration.Config +import app.aaps.core.interfaces.configuration.ExternalOptions +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.plugin.PluginDescription +import app.aaps.core.interfaces.profile.Profile +import app.aaps.core.interfaces.profile.ProfileFunction +import app.aaps.core.interfaces.protection.ProtectionCheck +import app.aaps.core.interfaces.pump.BlePreCheck +import app.aaps.core.interfaces.pump.DetailedBolusInfo +import app.aaps.core.interfaces.pump.Pump +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.pump.PumpInsulin +import app.aaps.core.interfaces.pump.PumpPluginBase +import app.aaps.core.interfaces.pump.PumpProfile +import app.aaps.core.interfaces.pump.PumpRate +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.core.interfaces.pump.defs.fillFor +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.queue.CustomCommand +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.core.interfaces.ui.IconsProvider +import app.aaps.core.interfaces.ui.UiInteraction +import app.aaps.core.interfaces.utils.fabric.FabricPrivacy +import app.aaps.core.keys.DoubleKey +import app.aaps.core.keys.IntKey +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.core.keys.interfaces.withEntries +import app.aaps.core.ui.R as CoreUiR +import app.aaps.core.ui.compose.icons.IcPluginCarelevo +import app.aaps.core.ui.compose.preference.PreferenceSubScreenDef +import app.aaps.pump.carelevo.ble.CarelevoBleSession +import app.aaps.pump.carelevo.ble.data.BleState +import app.aaps.pump.carelevo.ble.data.BondingState +import app.aaps.pump.carelevo.ble.data.DeviceModuleState +import app.aaps.pump.carelevo.ble.data.DeviceModuleState.Companion.codeToDeviceResult +import app.aaps.pump.carelevo.ble.data.NotificationState +import app.aaps.pump.carelevo.ble.data.PeripheralConnectionState +import app.aaps.pump.carelevo.ble.data.ServiceDiscoverState +import app.aaps.pump.carelevo.command.CarelevoActivationExecutor +import app.aaps.pump.carelevo.command.CmdTimeZoneUpdate +import app.aaps.pump.carelevo.command.CmdUpdateBuzzer +import app.aaps.pump.carelevo.command.CmdUpdateExpiredThreshold +import app.aaps.pump.carelevo.command.CmdUpdateLowInsulinNotice +import app.aaps.pump.carelevo.command.CmdUpdateMaxBolus +import app.aaps.pump.carelevo.common.CarelevoAlarmNotifier +import app.aaps.pump.carelevo.common.CarelevoObserveReceiver +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.common.keys.CarelevoBooleanPreferenceKey +import app.aaps.pump.carelevo.common.keys.CarelevoIntPreferenceKey +import app.aaps.pump.carelevo.common.model.PatchState +import app.aaps.pump.carelevo.compose.CarelevoComposeContent +import app.aaps.pump.carelevo.coordinator.CarelevoBasalProfileUpdateCoordinator +import app.aaps.pump.carelevo.coordinator.CarelevoBolusCoordinator +import app.aaps.pump.carelevo.coordinator.CarelevoConnectionCoordinator +import app.aaps.pump.carelevo.coordinator.CarelevoSettingsCoordinator +import app.aaps.pump.carelevo.coordinator.CarelevoTempBasalCoordinator +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.userSetting.CarelevoUserSettingInfoDomainModel +import app.aaps.pump.carelevo.domain.type.AlarmType.Companion.isCritical +import app.aaps.pump.carelevo.ext.transformNotificationStringResources +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.kotlin.plusAssign +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton +import kotlin.jvm.optionals.getOrNull + +@Singleton +class CarelevoPumpPlugin @Inject constructor( + aapsLogger: AAPSLogger, + rh: ResourceHelper, + preferences: Preferences, + commandQueue: CommandQueue, + private val aapsSchedulers: AapsSchedulers, + private val sp: SP, + private val fabricPrivacy: FabricPrivacy, + private val profileFunction: ProfileFunction, + private val context: Context, + private val protectionCheck: ProtectionCheck, + private val blePreCheck: BlePreCheck, + private val iconsProvider: IconsProvider, + private val config: Config, + private val uiInteraction: UiInteraction, + private var pumpEnactResultProvider: Provider, + private val carelevoPatch: CarelevoPatch, + + private val carelevoAlarmNotifier: CarelevoAlarmNotifier, + private val basalProfileUpdateCoordinator: CarelevoBasalProfileUpdateCoordinator, + private val bolusCoordinator: CarelevoBolusCoordinator, + private val tempBasalCoordinator: CarelevoTempBasalCoordinator, + private val connectionCoordinator: CarelevoConnectionCoordinator, + private val settingsCoordinator: CarelevoSettingsCoordinator, + private val activationExecutor: CarelevoActivationExecutor +) : PumpPluginBase( + pluginDescription = PluginDescription() + .mainType(PluginType.PUMP) + .composeContent { _ -> + CarelevoComposeContent( + aapsLogger = aapsLogger, + carelevoAlarmNotifier = carelevoAlarmNotifier, + protectionCheck = protectionCheck, + blePreCheck = blePreCheck, + iconsProvider = iconsProvider, + config = config + ) + } + .icon(IcPluginCarelevo) + .pluginName(R.string.carelevo) + .shortName(R.string.carelevo_shortname) + .description(R.string.carelevo_description), + ownPreferences = listOf(CarelevoBooleanPreferenceKey::class.java, CarelevoIntPreferenceKey::class.java), + aapsLogger, rh, preferences, commandQueue +), Pump { + + private var bleReceiverDisposable: Disposable? = null + private val pluginDisposable = CompositeDisposable() + + private var _pumpType: PumpType = PumpType.CAREMEDI_CARELEVO + private val _pumpDescription = PumpDescription().fillFor(_pumpType) + + private var scope: CoroutineScope? = null + private var lifecycleObserver: LifecycleEventObserver? = null + + // Auto-resume watchdog cadence + safety margin: poll this often, and reconcile once the stop window has + // elapsed plus this margin (so the patch has actually auto-resumed before AAPS clears its suspend). + private val autoResumePollMs = 30_000L + private val autoResumeMarginMs = 30_000L + + // The coroutine BLE session every pump op runs over. Field-injected — its @Inject constructor + // holds no eager BLE state. + @Inject lateinit var bleSession: CarelevoBleSession + + override suspend fun onStart() { + super.onStart() + + applyDefaultCageThresholdsIfNeeded() + registerPreferenceChangeObserver() + initializeOnStart() + registerBleReceiverIfNeeded() + startAlarmObserving() + startAutoResumeWatchdog() + // Consume patch-pushed frames (alarms, stop/basal-restart reports) whenever a session is open. + bleSession.unsolicitedHandler = carelevoPatch::onUnsolicited + } + + override suspend fun onStop() { + super.onStop() + aapsLogger.debug(LTag.PUMP, "onStop called") + bleSession.unsolicitedHandler = null + settingsCoordinator.clearUserSettings(pluginDisposable) + pluginDisposable.clear() + + // onStart/onStop run as separate, unjoined pluginScope coroutines (PluginBase), so a fast + // disable→re-enable can overlap them. Tear down only the generation captured here, and clear + // a field only if it still points at that generation — never clobber a scope/observer that a + // concurrent onStart may have just re-created (this fn suspends at withContext below). + val scopeToCancel = scope + val observerToRemove = lifecycleObserver + + scopeToCancel?.cancel() + if (scope === scopeToCancel) scope = null + + carelevoAlarmNotifier.stopObserving() + + observerToRemove?.let { observer -> + withContext(Dispatchers.Main) { + ProcessLifecycleOwner.get().lifecycle.removeObserver(observer) + } + } + if (lifecycleObserver === observerToRemove) lifecycleObserver = null + } + + private fun registerPreferenceChangeObserver() { + val newScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + scope = newScope + + // Settings pushes go through the queue (connect-before-execute) like every other patch op — a + // direct fire-and-forget write would silently fail while the pump idle-disconnects between commands. + preferences.observe(DoubleKey.SafetyMaxBolus) + .drop(1) + .onEach { commandQueue.customCommand(CmdUpdateMaxBolus(preferences.get(DoubleKey.SafetyMaxBolus))) } + .launchIn(newScope) + + preferences.observe(CarelevoIntPreferenceKey.CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS) + .drop(1) + .onEach { + val hours = sp.getInt(CarelevoIntPreferenceKey.CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS.key, 0) + commandQueue.customCommand(CmdUpdateExpiredThreshold(hours)) + } + .launchIn(newScope) + + preferences.observe(CarelevoIntPreferenceKey.CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS) + .drop(1) + .onEach { + val hours = sp.getInt(CarelevoIntPreferenceKey.CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS.key, 0) + // Zero = reminder off; skip enqueuing so the pump isn't reconnected just to no-op. + if (hours != 0) commandQueue.customCommand(CmdUpdateLowInsulinNotice(hours)) + } + .launchIn(newScope) + + preferences.observe(CarelevoBooleanPreferenceKey.CARELEVO_BUZZER_REMINDER) + .drop(1) + .onEach { + val on = sp.getBoolean(CarelevoBooleanPreferenceKey.CARELEVO_BUZZER_REMINDER.key, false) + commandQueue.customCommand(CmdUpdateBuzzer(on)) + } + .launchIn(newScope) + + // Deferred settings-sync recovery: a max-bolus / low-insulin change that couldn't reach the patch + // (changed while offline, or during a bolus) leaves a needXSyncPatch flag on the stored user + // settings; when the patch is booted again, push it through the queue. The combiner is PURE and the + // enqueue runs on IO. + pluginDisposable += Observable.combineLatest( + carelevoPatch.patchState, + carelevoPatch.infusionInfo, + carelevoPatch.userSettingInfo + ) { state, infusion, setting -> + computeSettingsSyncNeed(state.getOrNull(), infusion.getOrNull(), setting.getOrNull()) + } + .distinctUntilChanged() + .subscribeOn(aapsSchedulers.io) + .observeOn(aapsSchedulers.io) + .subscribe { need -> + newScope.launch { + need.maxBolusDose?.let { commandQueue.customCommand(CmdUpdateMaxBolus(it)) } + need.lowInsulinHours?.let { commandQueue.customCommand(CmdUpdateLowInsulinNotice(it)) } + } + } + } + + /** + * Auto-resume watchdog. A timed pump-stop makes the patch auto-resume at the stop duration, but with + * per-op BLE sessions there is no live link to receive the patch's stop-report push, and a 0x91 status + * read does not carry the suspend flag — so `isStopped` would stay latched until an app-initiated resume + * (issue #4993). + * + * Implemented as a resilient periodic check rather than a one-shot timer, so it is robust to the failure + * modes a single fire-and-forget timer has: + * - Each tick is independently guarded, so a transient reconcile failure neither kills the watchdog + * (it simply retries next tick) nor is permanent. + * - Elapsed time is measured from a DURABLE anchor ([CarelevoBasalInfusionInfoDomainModel.updatedAt], + * persisted at stop time and untouched by 0x91 status reads), so an app restart mid-stop still + * resumes on schedule instead of re-arming a full window. + * - A status read that momentarily resurrects `isStopped` is re-cleared on the next tick, because the + * anchor is stable. + */ + private fun startAutoResumeWatchdog() { + scope?.launch { + while (isActive) { + try { + checkAutoResume() + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + aapsLogger.error(LTag.PUMPCOMM, "auto-resume check failed (will retry)", e) + } + delay(autoResumePollMs) + } + } + } + + /** One watchdog tick: reconcile a timed stop whose window has elapsed. Idempotent — safe to call repeatedly. */ + private suspend fun checkAutoResume() { + val patchInfo = carelevoPatch.patchInfo.value?.getOrNull() ?: return + if (patchInfo.isStopped != true) return + val stopMinutes = patchInfo.stopMinutes ?: return + if (stopMinutes <= 0) return + // Durable stop-start anchor: basalInfusionInfo.updatedAt is set at persistStopped, survives an app + // restart (persisted), and is not moved by 0x91 status reads (which only rewrite patchInfo). Skip + // until infusion info is loaded. + val stopStartedAt = carelevoPatch.infusionInfo.value?.getOrNull()?.basalInfusionInfo?.updatedAt?.millis ?: return + val elapsedMs = System.currentTimeMillis() - stopStartedAt + if (elapsedMs < stopMinutes * 60_000L + autoResumeMarginMs) return + aapsLogger.info(LTag.PUMPCOMM, "auto-resume: ${stopMinutes}min stop elapsed (${elapsedMs / 1000}s) -> reconcile") + carelevoPatch.reconcileAutoResumed() + commandQueue.readStatus("Carelevo auto-resume") + } + + private data class SettingsSyncNeed(val maxBolusDose: Double?, val lowInsulinHours: Int?) + + /** Pure: which deferred patch settings still need pushing (flag set AND the patch is booted). */ + private fun computeSettingsSyncNeed( + state: PatchState?, + infusion: CarelevoInfusionInfoDomainModel?, + setting: CarelevoUserSettingInfoDomainModel? + ): SettingsSyncNeed { + if (state !is PatchState.ConnectedBooted || setting == null) return SettingsSyncNeed(null, null) + // Max-bolus is a device safety cap — do not re-push mid-bolus (mirrors the use case's own guard). + // "No bolus running" requires BOTH channels idle (the deleted observeSyncPatch had this as `||`, + // which was true during a single-channel bolus and triggered a spurious mid-bolus reconnect). + val noBolusRunning = infusion?.extendBolusInfusionInfo == null && infusion?.immeBolusInfusionInfo == null + val maxBolusDose = if (setting.needMaxBolusDoseSyncPatch && noBolusRunning) setting.maxBolusDose ?: 0.0 else null + val lowInsulinHours = if (setting.needLowInsulinNoticeAmountSyncPatch) setting.lowInsulinNoticeAmount ?: 0 else null + return SettingsSyncNeed(maxBolusDose, lowInsulinHours) + } + + private fun initializeOnStart() { + // Run initialization directly on start instead of gating it on EventAppInitialized. + // onStart() is now a suspend function launched fire-and-forget, so the (non-replayed) + // EventAppInitialized could fire before this subscription was registered — leaving the + // patch uninitialized and appearing deactivated after an app update (dev fixed the same + // race in eopatch). initPatchOnce() does not depend on other plugins; the basal profile is + // applied best-effort here and, if not yet available, is set later via setNewBasalProfile(). + pluginDisposable += carelevoPatch.initPatchOnce() + .subscribeOn(aapsSchedulers.io) + .timeout(5, TimeUnit.SECONDS) + .onErrorComplete() + .doOnSubscribe { aapsLogger.debug(LTag.PUMPCOMM, "onStart: 1) initPatchOnce waiting") } + .doOnComplete { aapsLogger.debug(LTag.PUMPCOMM, "onStart: 1) initPatchOnce completed") } + .andThen( + Completable.fromAction { + val profile = runBlocking { profileFunction.getProfile() } + if (profile != null) { + carelevoPatch.setProfile(profile) + aapsLogger.debug(LTag.PUMPCOMM, "onStart: 3) setProfile done: $profile") + } else { + aapsLogger.debug(LTag.PUMPCOMM, "onStart: 3) profile not ready, deferring to setNewBasalProfile") + } + } + ) + .subscribe( + { aapsLogger.debug(LTag.PUMPCOMM, "onStart: ALL COMPLETE") }, + { e -> aapsLogger.error(LTag.PUMPCOMM, "onStart: chain error", e) } + ) + + pluginDisposable += carelevoPatch.patchInfo + .observeOn(aapsSchedulers.io) + .subscribe({ + _reservoirLevel.value = PumpInsulin(it.getOrNull()?.insulinRemain ?: 0.0) + _batteryLevel.value = 0 + }, fabricPrivacy::logException) + } + + private fun registerBleReceiverIfNeeded() { + if (bleReceiverDisposable?.isDisposed == false) return + + // Seed the adapter state so isBluetoothEnabled()/patchState resolve before the first broadcast — + // the receiver is now the ONLY btState source (adapter on/off; there is no resting link to track). + carelevoPatch.onBluetoothStateChanged(currentAdapterBleState()) + + // The emulated patch has no radio, so the state seeded above is the whole truth. Subscribing + // anyway would let the host's adapter being switched off report OFF and kill a live emulated + // session — the one thing emulation is supposed to be immune to. + if (isEmulating) return + + bleReceiverDisposable = CarelevoObserveReceiver(context, createBluetoothIntentFilter()) + .subscribe { intent -> + aapsLogger.debug(LTag.PUMPBTCOMM, "CarelevoObserveReceiver called: ${intent.action}") + if (intent.action == BluetoothAdapter.ACTION_STATE_CHANGED) { + val value = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR) + if (value in setOf(BluetoothAdapter.STATE_ON, BluetoothAdapter.STATE_OFF, BluetoothAdapter.STATE_TURNING_ON, BluetoothAdapter.STATE_TURNING_OFF)) { + carelevoPatch.onBluetoothStateChanged(adapterBleState(value.codeToDeviceResult())) + } + } + } + + bleReceiverDisposable?.let { pluginDisposable.add(it) } + } + + /** + * The emulated patch is reachable regardless of the host adapter, so report ON without asking it. + * Without this the whole driver — every `isBluetoothEnabled()` gate in the coordinators and view + * models — refuses before a request ever reaches the emulated transport. Mirrors the way Dana + * skips its BLE pre-check while emulating; Dana needs no equivalent here only because it never + * reads the adapter's enabled state at all. + */ + private fun currentAdapterBleState(): BleState { + val enabled = isEmulating || + (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter?.isEnabled == true + return adapterBleState(if (enabled) DeviceModuleState.DEVICE_STATE_ON else DeviceModuleState.DEVICE_STATE_OFF) + } + + private val isEmulating: Boolean get() = config.isEnabled(ExternalOptions.EMULATE_CARELEVO) + + /** Adapter-level state only — bond/discovery/notification fields are per-session now and never tracked. */ + private fun adapterBleState(enabled: DeviceModuleState): BleState = BleState( + isEnabled = enabled, + isBonded = BondingState.BOND_NONE, + isServiceDiscovered = ServiceDiscoverState.DISCOVER_STATE_NONE, + isConnected = PeripheralConnectionState.CONN_STATE_NONE, + isNotificationEnabled = NotificationState.NOTIFICATION_NONE + ) + + private fun createBluetoothIntentFilter(): IntentFilter { + return IntentFilter().apply { + addAction(BluetoothAdapter.ACTION_STATE_CHANGED) + } + } + + private fun applyDefaultCageThresholdsIfNeeded() { + if (sp.getBoolean(CarelevoBooleanPreferenceKey.CARELEVO_CAGE_DEFAULT_APPLIED.key, false)) return + + sp.edit { + putInt(IntKey.OverviewCageWarning.key, 96) + putInt(IntKey.OverviewCageCritical.key, 168) + putBoolean(CarelevoBooleanPreferenceKey.CARELEVO_CAGE_DEFAULT_APPLIED.key, true) + } + } + + private suspend fun startAlarmObserving() { + aapsLogger.debug(LTag.NOTIFICATION, "startAlarmObserving:: onStart") + + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_START) { + aapsLogger.debug(LTag.NOTIFICATION, "Foreground transition -> refresh alarms") + carelevoAlarmNotifier.refreshAlarms() + } + } + lifecycleObserver = observer + withContext(Dispatchers.Main) { + ProcessLifecycleOwner.get().lifecycle.addObserver(observer) + } + + carelevoAlarmNotifier.startObserving { alarms -> + aapsLogger.debug(LTag.NOTIFICATION, "observe alarms size=${alarms.size}, $alarms") + handleAlarms(alarms) + } + } + + // Critical alarms already escalated to the global alarm (so a re-emission of the same list + // doesn't re-fire the sound). Pruned to the currently-active set on every pass. + private val globallyAlarmedIds = mutableSetOf() + + private fun handleAlarms(alarms: List) { + aapsLogger.debug(LTag.NOTIFICATION, "startAlarmObserving handleAlarms:: $alarms") + globallyAlarmedIds.retainAll(alarms.map { it.alarmId }.toSet()) + if (alarms.isEmpty()) return + + val critical = alarms.filter { it.alarmType.isCritical() } + if (critical.isNotEmpty()) { + // filter-with-add: keeps only the not-yet-escalated alarms AND marks them escalated. + val fresh = critical.filter { globallyAlarmedIds.add(it.alarmId) } + if (carelevoAlarmNotifier.alarmHostActive) { + // The in-app host is mounted — it presents the full-screen alarm and starts the + // sound itself (CarelevoAlarmHost/CarelevoAlarmViewModel). + aapsLogger.debug(LTag.NOTIFICATION, "critical alarm handled by compose host") + } else if (fresh.isNotEmpty()) { + // No in-app surface (backgrounded, or user on another screen): fire the global AAPS + // alarm (sound + full-screen intent) — a critical patch alarm must NEVER depend on + // the user having the Carelevo screen open. + val first = fresh.first() + uiInteraction.runAlarm( + status = rh.gs(first.cause.transformNotificationStringResources().first), + title = rh.gs(R.string.carelevo), + soundId = CoreUiR.raw.error + ) + } + } else { + carelevoAlarmNotifier.showTopNotification(alarms) + } + } + + override fun getPreferenceScreenContent() = PreferenceSubScreenDef( + key = "carelevo_settings", + titleResId = R.string.carelevo, + items = listOf( + CarelevoIntPreferenceKey.CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS.withEntries( + (20..50 step 5).associateWith { "$it U" } + ), + CarelevoIntPreferenceKey.CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS.withEntries( + (24..167 step 1).associateWith { "$it ${rh.gs(app.aaps.core.interfaces.R.string.hours)}" } + ), + CarelevoBooleanPreferenceKey.CARELEVO_BUZZER_REMINDER + ), + icon = pluginDescription.icon + ) + + // A patch is "configured" once activation has persisted a patch record (patchInfo present) — the + // same signal isInitialized() gates on first, so isInitialized() implies isConfigured() and the + // contract invariant !isConfigured() => !isInitialized() holds by construction. Intentionally + // independent of BLE: an attached-but-disconnected patch is still configured and may be delivering. + override fun isConfigured(): Boolean = + carelevoPatch.patchInfo.value?.getOrNull() != null + + override fun isInitialized(): Boolean { + return connectionCoordinator.isInitialized() + } + + override fun isSuspended(): Boolean { + // Real delivery-suspend (pump stopped by the user), NOT the BLE connection state. Otherwise, a + // normal idle disconnect (NotConnectedBooted, now that disconnect() actually disconnects) would + // be reported as suspended and surface as an error/suspended icon on the overview. + return carelevoPatch.patchInfo.value?.getOrNull()?.isStopped ?: false + } + + override fun isBusy(): Boolean { + return false + } + + override fun isConnected(): Boolean { + return connectionCoordinator.isConnected() + } + + override fun isConnecting(): Boolean { + return connectionCoordinator.isConnecting() + } + + override fun isHandshakeInProgress(): Boolean { + return false + } + + override fun connect(reason: String) { + connectionCoordinator.connect(reason) + } + + override fun disconnect(reason: String) { + connectionCoordinator.disconnect(reason) + } + + override fun stopConnecting() { + connectionCoordinator.stopConnecting() + } + + override suspend fun getPumpStatus(reason: String) = readInfusionInfo() + + /** + * Status read over the [app.aaps.pump.carelevo.ble.BleClient] stack: read Infusion Info (0x31 → 0x91) + * on the session's own connection and persist it through `carelevoPatch.applyInfusionInfoReport` — + * reservoir/pump-state/totals all refresh. Runs on + * the QueueWorker thread, blocked inside this status read. + */ + private suspend fun readInfusionInfo() { + val address = carelevoPatch.getPatchInfoAddress() ?: run { + aapsLogger.warn(LTag.PUMPCOMM, "newBle.readInfusionInfo skipped: no patch address") + return + } + try { + val info = bleSession.readInfusionInfo(address) + carelevoPatch.applyInfusionInfoReport( + runningMinutes = info.runningMinutes, + remains = info.insulinRemaining, + infusedTotalBasalAmount = info.infusedTotalBasalAmount, + infusedTotalBolusAmount = info.infusedTotalBolusAmount, + pumpStateRaw = info.pumpStateRaw, + modeRaw = info.modeRaw + ) + _lastDataTime.value = System.currentTimeMillis() + aapsLogger.info( + LTag.PUMPCOMM, + "newBle.readInfusionInfo OK remains=${info.insulinRemaining} basal=${info.infusedTotalBasalAmount} " + + "bolus=${info.infusedTotalBolusAmount} pumpState=${info.pumpStateRaw} mode=${info.modeRaw} running=${info.runningMinutes}" + ) + } catch (e: Throwable) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.readInfusionInfo FAILED", e) + } + } + + override suspend fun setNewBasalProfile(profile: PumpProfile): PumpEnactResult { + aapsLogger.debug(LTag.PUMP, "setNewBasalProfile called - ${carelevoPatch.resolvePatchState()}") + _lastDataTime.value = System.currentTimeMillis() + // PROFILE_SET_OK / FAILED_UPDATE_PROFILE are posted centrally by the CommandQueue from the returned + // success/enacted (unified across pumps) — this method only returns the right values. + val result = when (carelevoPatch.resolvePatchState()) { + is PatchState.NotConnectedNotBooting -> { + // No active patch yet — store the profile for when a patch is activated. A deferred write, + // not an actual change, so enacted=false (no PROFILE_SET_OK); success=true keeps the + // not-ready case out of the failure alarm (matches the other queue-managed pumps). + carelevoPatch.setProfile(profile) + pumpEnactResultProvider.get().success(true).enacted(false) + } + + else -> { + // Patch present. setNewBasalProfile runs on the queue worker AFTER the queue guaranteed a + // fully-connected link, so this is the live push path even if the cached patchState briefly + // reads NotConnectedBooted. updateBasalProfile returns the real success/enacted result. + updateBasalProfile(profile) + } + } + aapsLogger.debug(LTag.PUMP, "result success=${result.success} enacted=${result.enacted} comment=${result.comment}") + return result + } + + private fun updateBasalProfile(profile: Profile): PumpEnactResult { + return basalProfileUpdateCoordinator.updateBasalProfile( + profile = profile, + cancelExtendedBolus = { + bolusCoordinator.cancelExtendedBolus( + serialNumber = serialNumber(), + onLastDataUpdated = { _lastDataTime.value = System.currentTimeMillis() } + ) + }, + cancelTempBasal = { + tempBasalCoordinator.cancelTempBasal( + serialNumber = serialNumber(), + onLastDataUpdated = { _lastDataTime.value = System.currentTimeMillis() } + ) + }, + onProfileUpdated = { updatedProfile -> + _lastDataTime.value = System.currentTimeMillis() + carelevoPatch.setProfile(updatedProfile) + } + ) + } + + override fun isThisProfileSet(profile: PumpProfile): Boolean { + return carelevoPatch.checkIsSameProfile(profile) + } + + // Activation ops (safety check, …) are queued so they get the CommandQueue's managed + // connect-before-execute / reconnect lifecycle instead of a direct BLE call. + override fun executeCustomCommand(customCommand: CustomCommand): PumpEnactResult? = + activationExecutor.execute(customCommand) + + private val _lastDataTime = MutableStateFlow(0L) + override val lastDataTime: StateFlow = _lastDataTime.asStateFlow() + + override val lastBolusTime: StateFlow + get() = bolusCoordinator.lastBolusTime + + override val lastBolusAmount: StateFlow + get() = bolusCoordinator.lastBolusAmount + + override val baseBasalRate: PumpRate + get() = PumpRate(carelevoPatch.profile.value?.getOrNull()?.getBasal() ?: 0.0) + + private val _reservoirLevel = MutableStateFlow(PumpInsulin(0.0)) + override val reservoirLevel: StateFlow = _reservoirLevel + + private val _batteryLevel = MutableStateFlow(null) + override val batteryLevel: StateFlow = _batteryLevel + + // start imme bolus infusion + override suspend fun deliverTreatment(detailedBolusInfo: DetailedBolusInfo): PumpEnactResult { + return bolusCoordinator.deliverTreatment( + detailedBolusInfo = detailedBolusInfo, + serialNumber = serialNumber(), + onLastDataUpdated = { _lastDataTime.value = System.currentTimeMillis() }, + pluginDisposable = pluginDisposable + ) + } + + // cancel imme bolus + override fun stopBolusDelivering() { + bolusCoordinator.cancelImmediateBolus( + serialNumber = serialNumber(), + onLastDataUpdated = { _lastDataTime.value = System.currentTimeMillis() } + ) + } + + // enforceNew is intentionally ignored on all three TBR entry points: with per-op sessions there + // is no resting link whose state a "keep the running TBR" optimization could trust, so Carelevo + // always re-programs the requested TBR (the Pump contract explicitly allows this; the flag is + // only an optimization hint). + override suspend fun setTempBasalAbsolute(absoluteRate: Double, durationInMinutes: Int, enforceNew: Boolean, tbrType: PumpSync.TemporaryBasalType): PumpEnactResult { + return tempBasalCoordinator.setTempBasalAbsolute( + absoluteRate = absoluteRate, + durationInMinutes = durationInMinutes, + tbrType = tbrType, + serialNumber = serialNumber(), + onLastDataUpdated = { _lastDataTime.value = System.currentTimeMillis() } + ) + } + + override suspend fun setTempBasalPercent(percent: Int, durationInMinutes: Int, enforceNew: Boolean, tbrType: PumpSync.TemporaryBasalType): PumpEnactResult { + return tempBasalCoordinator.setTempBasalPercent( + percent = percent, + durationInMinutes = durationInMinutes, + tbrType = tbrType, + serialNumber = serialNumber(), + onLastDataUpdated = { _lastDataTime.value = System.currentTimeMillis() } + ) + } + + override suspend fun cancelTempBasal(enforceNew: Boolean): PumpEnactResult { + return tempBasalCoordinator.cancelTempBasal( + serialNumber = serialNumber(), + onLastDataUpdated = { _lastDataTime.value = System.currentTimeMillis() } + ) + } + + override suspend fun setExtendedBolus(insulin: Double, durationInMinutes: Int): PumpEnactResult { + return bolusCoordinator.setExtendedBolus( + insulin = insulin, + durationInMinutes = durationInMinutes, + serialNumber = serialNumber() + ) + } + + override suspend fun cancelExtendedBolus(): PumpEnactResult { + return bolusCoordinator.cancelExtendedBolus( + serialNumber = serialNumber(), + onLastDataUpdated = { _lastDataTime.value = System.currentTimeMillis() } + ) + } + + override fun manufacturer(): ManufacturerType { + return ManufacturerType.CareMedi + } + + override fun model(): PumpType { + return PumpType.CAREMEDI_CARELEVO + } + + override fun serialNumber(): String { + return carelevoPatch.patchInfo.value?.getOrNull()?.manufactureNumber ?: "" + } + + override val pumpDescription: PumpDescription + get() = _pumpDescription + + override val isFakingTempsByExtendedBoluses: Boolean + get() = false + + override suspend fun loadTDDs(): PumpEnactResult { + return pumpEnactResultProvider.get() + } + + override fun canHandleDST(): Boolean { + return false + } + + override suspend fun timezoneOrDSTChanged(timeChangeType: TimeChangeType) { + super.timezoneOrDSTChanged(timeChangeType) + // Route through the queue (connect-before-execute) like every other patch op. The pump + // idle-disconnects between commands, so a direct fire-and-forget write would silently fail + // while resting. Skip ONLY when no patch is active; if the patch is present but its insulinRemain + // is not yet known (e.g. before the first status read after reconnect) still push with 0 rather + // than dropping the clock update (original `?: 0` semantics). + val patchInfo = carelevoPatch.patchInfo.value?.getOrNull() ?: return + val insulin = patchInfo.insulinRemain?.toInt() ?: 0 + val result = commandQueue.customCommand(CmdTimeZoneUpdate(insulinAmount = insulin)) + if (result.success) _lastDataTime.value = System.currentTimeMillis() + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/BleClient.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/BleClient.kt new file mode 100644 index 000000000000..733759b1f7f7 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/BleClient.kt @@ -0,0 +1,182 @@ +package app.aaps.pump.carelevo.ble + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow + +/** + * Request/response client over a [app.aaps.pump.carelevo.ble.gatt.GattConnection]. + * + * Serializes outgoing writes with a mutex so at most one request is in flight at any + * moment, and correlates each request with the peripheral's notification by opcode + * pair (and, for the immediate-bolus command, by echoed actionId). + * + * Notifications arriving outside any active request are forwarded to + * [unsolicitedEvents] so alarm/status-report consumers can subscribe independently. + */ +interface BleClient { + + /** + * Send [cmd], suspend until the matching response notification arrives, then + * return the decoded response. + * + * Does **not** impose a timeout — callers wrap in `withTimeout(...)` if a + * deadline is wanted. Different pump operations have legitimately different + * deadlines (e.g. safety-check is ~100 s, most requests are ~3 s), so the + * policy lives at the caller. + * + * Throws: + * - [BleDisconnectedException] if the GATT connection drops while the request + * is pending. + * - [app.aaps.pump.carelevo.ble.gatt.GattWriteException] if the BLE write fails. + * - [kotlinx.coroutines.CancellationException] on coroutine cancellation + * (including `withTimeout`). + */ + suspend fun request(cmd: BleCommand): R + + /** + * Send [cmd], suspend until **all** of its [BleMultiCommand.expectedResponseOpcodes] + * have arrived (in any order), then decode them together. + * + * For requests the pump answers with more than one notification — e.g. Patch + * Information Inquiry (`0x33`), answered by RPT1 (`0x93`) + RPT2 (`0x94`). The first + * notification seen for each expected opcode wins; a duplicate or any non-matching + * notification (alarm, status push) falls through to [unsolicitedEvents]. + * + * Same deadline policy as [request] — no built-in timeout, wrap in `withTimeout(...)`. + * A single deadline wraps the whole call (not per-response). + * + * Throws [BleDisconnectedException] / [app.aaps.pump.carelevo.ble.gatt.GattWriteException] / + * [kotlinx.coroutines.CancellationException] on the same conditions as [request]. + */ + suspend fun requestMultiple(cmd: BleMultiCommand): R + + /** + * Send [cmd] and return a cold [Flow] of every decoded notification matching + * [BleStreamCommand.expectedResponseOpcode], completing when [BleStreamCommand.isTerminal] + * is `true` for a decoded response. + * + * For streaming/progress requests — e.g. Safety Check (`0x12`): the pump emits + * repeated progress reports then a terminal SUCCESS/error, all on `0x72`. The + * request write happens when the returned flow is collected; the request slot is + * held for the whole stream (single-in-flight), so a concurrent [request] waits + * until the stream terminates. + * + * The flow throws [BleDisconnectedException] if the link drops mid-stream. As with + * [request], impose a deadline by wrapping collection in `withTimeout(...)`. + * + * **Do not call any [BleClient] method from inside this flow's collector.** The + * request slot (a non-reentrant mutex) is held for the whole stream, so a reentrant + * `request`/`requestMultiple`/`requestStream` issued from the `collect {}` block + * self-deadlocks. Collect the stream to completion first, then issue the next + * request outside the collector. + */ + fun requestStream(cmd: BleStreamCommand): Flow + + /** + * Hot stream of notifications that did not match any active request — alarms, + * status pushes, cannula-insertion events, etc. Subscribers see only events + * emitted after they subscribe (no replay). Independent from [request] — + * alarm handling and request/response correlation do not interfere. + */ + val unsolicitedEvents: SharedFlow +} + +/** + * A single CareLevo protocol request, paired with the Kotlin type of its expected + * response. Implementations encode the outgoing byte-array, declare the opcode + * pair, and decode the response bytes back into a typed model. + */ +interface BleCommand { + + /** Opcode byte (position 0) of the outgoing write. */ + val requestOpcode: Byte + + /** Opcode byte (position 0) that the peripheral is expected to reply with. */ + val expectedResponseOpcode: Byte + + /** + * Optional correlation byte at position 1 of the response. Non-null for commands + * that echo a caller-chosen identifier (e.g. immediate bolus `actionId`). When + * non-null, [BleClient] only accepts responses whose byte-1 equals this value — + * belt-and-braces against a stale or unsolicited message with the same opcode. + */ + val correlationByte: Byte? get() = null + + /** Full outgoing payload, starting with [requestOpcode] at byte 0. */ + fun encode(): ByteArray + + /** Parse the full response payload (byte 0 is [expectedResponseOpcode]). */ + fun decode(responsePayload: ByteArray): R +} + +/** + * A CareLevo request that a single write answers with **more than one** notification — + * e.g. Patch Information Inquiry (`0x33`) → RPT1 (`0x93`) + RPT2 (`0x94`). + * + * [BleClient.requestMultiple] collects the first notification seen for each opcode in + * [expectedResponseOpcodes] (arrival order irrelevant) and completes once every opcode + * in the set has been received, then hands the collected payloads to [decode]. + */ +interface BleMultiCommand { + + /** Opcode byte (position 0) of the outgoing write. */ + val requestOpcode: Byte + + /** + * The full set of response opcodes this request produces. The request completes + * only once one notification for **each** opcode has arrived. Must be non-empty. + */ + val expectedResponseOpcodes: Set + + /** Full outgoing payload, starting with [requestOpcode] at byte 0. */ + fun encode(): ByteArray + + /** + * Parse the collected responses, keyed by their opcode (byte 0 of each payload). + * Every key in [expectedResponseOpcodes] is guaranteed present. + */ + fun decode(responses: Map): R +} + +/** + * A CareLevo request whose response is a **stream** — repeated notifications on one + * opcode, terminated by a distinguished response. E.g. Safety Check (`0x12`): the pump + * emits progress reports (`REP_REQUEST`) then a terminal SUCCESS/error, all on `0x72`. + * + * [BleClient.requestStream] decodes each notification matching [expectedResponseOpcode] + * and emits it; the stream completes when [isTerminal] returns `true` for a decoded + * response. Non-matching notifications fall through to [BleClient.unsolicitedEvents]. + */ +interface BleStreamCommand { + + /** Opcode byte (position 0) of the outgoing write. */ + val requestOpcode: Byte + + /** Opcode byte (position 0) each streamed response notification carries. */ + val expectedResponseOpcode: Byte + + /** Full outgoing payload, starting with [requestOpcode] at byte 0. */ + fun encode(): ByteArray + + /** Parse one streamed response payload (byte 0 is [expectedResponseOpcode]). */ + fun decode(responsePayload: ByteArray): R + + /** `true` when [response] is the final one — the stream completes after emitting it. */ + fun isTerminal(response: R): Boolean +} + +/** Marker type for decoded CareLevo responses. */ +interface BleResponse + +/** + * A notification that did not match any active request — an alarm, status report, + * or other peripheral-initiated push. Carries the raw payload; higher layers parse + * it via the existing parser registry. + */ +data class UnsolicitedMessage( + val opcode: Byte, + val payload: ByteArray +) + +/** Thrown by a pending [BleClient.request] if the GATT connection drops. */ +class BleDisconnectedException(message: String = "connection dropped") : RuntimeException(message) diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/BleClientImpl.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/BleClientImpl.kt new file mode 100644 index 000000000000..33d2e6c766fb --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/BleClientImpl.kt @@ -0,0 +1,320 @@ +package app.aaps.pump.carelevo.ble + +import app.aaps.pump.carelevo.ble.gatt.GattConnState +import app.aaps.pump.carelevo.ble.gatt.GattConnection +import app.aaps.pump.carelevo.ble.gatt.GattEvent +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.UUID +import java.util.concurrent.atomic.AtomicReference + +/** + * Production implementation of [BleClient]. + * + * Correlation rules (see [BleClientContractTest] in tests for the full spec): + * - A single [requestMutex] serializes outgoing requests — at most one in flight, + * whether single-response, multi-response, or streaming. + * - Before calling [GattConnection.writeCharacteristic], the client registers a + * [Waiter] describing the response(s) it expects. This ordering eliminates the + * response-during-write race. + * - A long-lived collector on the injected [scope] reads [GattConnection.events] and + * offers each [GattEvent.Notification] to the active [Waiter]; anything the waiter + * does not consume is forwarded to [_unsolicitedEvents]. + * - A [GattEvent.ConnectionStateChanged] with [GattConnState.DISCONNECTED] aborts the + * pending waiter (with [BleDisconnectedException]) atomically. + * + * The event collector is the single point through which every request completes, so it + * is hardened to survive any callback failure: the [waiter] is held in an + * [AtomicReference] (so a disconnect can atomically take-and-abort whatever request is + * current without clobbering a concurrently-registered one), consumer-supplied + * [BleStreamCommand.decode]/[BleStreamCommand.isTerminal] are guarded, and the collect + * body is wrapped so no unforeseen throw can permanently stop routing. + */ +class BleClientImpl( + private val gatt: GattConnection, + private val writeUuid: UUID, + @Suppress("unused") + private val notifyUuid: UUID, + scope: CoroutineScope +) : BleClient { + + // DROP_OLDEST + tryEmit: unsolicited fan-out (alarms/status are lossy pushes) can + // never back-pressure the event collector and stall in-flight request correlation. + private val _unsolicitedEvents = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + override val unsolicitedEvents: SharedFlow = _unsolicitedEvents.asSharedFlow() + + private val requestMutex = Mutex() + + /** + * The response(s) the in-flight request is waiting for. Exactly one is active at a + * time (guarded by [requestMutex] on the caller side). Held in an [AtomicReference] + * because the event collector aborts it on disconnect **outside** the mutex (a + * stream holds the mutex for its whole 100-210 s duration, so the collector must + * stay lock-free): [AtomicReference.getAndSet] takes-and-nulls atomically so a + * disconnect can never null a freshly-registered waiter without also aborting it. + */ + private val waiterRef = AtomicReference(null) + + private sealed interface Waiter { + + /** Try to consume the notification [payload] (opcode is [opcode]). Return `true` iff consumed. */ + fun offer(opcode: Byte, payload: ByteArray): Boolean + + /** Abort this pending request (link dropped, or an unforeseen router failure). */ + fun abort(cause: Throwable) + + /** + * Called by the requesting coroutine when it unwinds WITHOUT having consumed the + * response (cancellation/timeout). After this returns, [offer] consumes nothing + * more, so late frames fall through to [unsolicitedEvents]. Returns any frame(s) + * this waiter had already absorbed but never delivered to the caller, so the + * caller can re-route them to unsolicited instead of silently swallowing a + * response the pump really sent (e.g. the ack of a bolus that DID start while + * our side timed out). + */ + fun abandon(): List + } + + private class SingleWaiter( + private val expectedOpcode: Byte, + private val correlationByte: Byte?, + private val deferred: CompletableDeferred + ) : Waiter { + + override fun offer(opcode: Byte, payload: ByteArray): Boolean { + if (opcode != expectedOpcode) return false + val correlationOk = correlationByte == null || + (payload.size > 1 && payload[1] == correlationByte) + if (!correlationOk) return false + // complete() returns false if already settled — a late duplicate (already + // completed) or an abandoned caller (deferred cancelled by abandon()) — so + // routeNotification forwards the frame to unsolicited either way. + return deferred.complete(payload) + } + + override fun abort(cause: Throwable) { + deferred.completeExceptionally(cause) + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun abandon(): List { + // cancel() races atomically against offer()'s complete(): exactly one wins. + // If cancel wins, a concurrent/late offer returns false and the frame is + // routed to unsolicited by the collector. If complete already won, the + // payload sits in a deferred nobody will await — hand it back for re-routing. + deferred.cancel() + return if (deferred.isCompleted && !deferred.isCancelled) listOf(deferred.getCompleted()) else emptyList() + } + } + + private class MultiWaiter( + private val remaining: MutableSet, + private val deferred: CompletableDeferred> + ) : Waiter { + + private val collected = mutableMapOf() + + override fun offer(opcode: Byte, payload: ByteArray): Boolean { + // Abandoned by the caller — stop consuming so frames route to unsolicited. + // (offer runs only on the single collector coroutine; isCancelled is the + // cross-thread signal from abandon(). A frame absorbed into `collected` in + // the narrow window before cancel lands is dropped with its partial map — + // a partial multi-response is unusable anyway and pairing redoes the round.) + if (deferred.isCancelled) return false + // First notification per expected opcode wins; a duplicate opcode is no + // longer in `remaining`, so it falls through to unsolicited. + if (!remaining.remove(opcode)) return false + collected[opcode] = payload + if (remaining.isEmpty() && !deferred.complete(collected.toMap())) return false + return true + } + + override fun abort(cause: Throwable) { + deferred.completeExceptionally(cause) + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun abandon(): List { + deferred.cancel() + return if (deferred.isCompleted && !deferred.isCancelled) deferred.getCompleted().values.toList() else emptyList() + } + } + + private class StreamWaiter( + private val expectedOpcode: Byte, + private val decode: (ByteArray) -> R, + private val isTerminal: (R) -> Boolean, + private val channel: Channel + ) : Waiter { + + override fun offer(opcode: Byte, payload: ByteArray): Boolean { + if (opcode != expectedOpcode) return false + // Both consumer-supplied callbacks run here on the shared event collector, + // so both are guarded: a throw must end THIS stream, never escape onto the + // collector and brick routing for every future request. + val decoded = try { + decode(payload) + } catch (t: Throwable) { + channel.close(t) + return true + } + // Channel is UNLIMITED, so a success means it was accepted; a failure means + // it is already closed (terminal already delivered) — treat a late same-opcode + // frame as not-consumed so it falls through to unsolicited. + if (!channel.trySend(decoded).isSuccess) return false + val terminal = try { + isTerminal(decoded) + } catch (t: Throwable) { + channel.close(t) + return true + } + if (terminal) channel.close() + return true + } + + override fun abort(cause: Throwable) { + channel.close(cause) + } + + override fun abandon(): List { + // cancel() (not close()) so a concurrent trySend fails and the frame routes + // to unsolicited. Frames already decoded into the channel are typed R, not + // raw bytes — nothing to re-route; stream frames are progress reports whose + // loss the stream consumer already treats as a failed operation. + channel.cancel() + return emptyList() + } + } + + init { + scope.launch { + gatt.events.collect { evt -> + try { + onEvent(evt) + } catch (t: CancellationException) { + throw t + } catch (t: Throwable) { + // The sole event router must never die. Callback throws are already + // guarded above; this is a last-resort backstop — abort the active + // request so its caller fails fast rather than hanging on a response + // that will never route, and keep the collector alive. + waiterRef.getAndSet(null)?.abort(t) + } + } + } + } + + private fun onEvent(evt: GattEvent) { + when (evt) { + is GattEvent.Notification -> routeNotification(evt.payload) + + is GattEvent.ConnectionStateChanged -> { + if (evt.state == GattConnState.DISCONNECTED) { + // Atomically take-and-null so a late-arriving notification falls + // through to unsolicited rather than hitting an aborted waiter. + waiterRef.getAndSet(null)?.abort(BleDisconnectedException()) + } + } + + is GattEvent.ServicesDiscovered, + is GattEvent.WriteAck -> Unit + } + } + + private fun routeNotification(payload: ByteArray) { + if (payload.isEmpty()) return + val opcode = payload[0] + if (waiterRef.get()?.offer(opcode, payload) == true) return + // tryEmit never suspends (DROP_OLDEST) so the collector can't be back-pressured. + _unsolicitedEvents.tryEmit(UnsolicitedMessage(opcode, payload)) + } + + /** + * Tears down [waiter] after its requesting coroutine unwound without consuming the + * response (cancellation/timeout), re-routing any frame the waiter had already + * absorbed to [unsolicitedEvents] — see [Waiter.abandon]. + */ + private fun abandonAndReroute(waiter: Waiter) { + for (frame in waiter.abandon()) { + if (frame.isNotEmpty()) _unsolicitedEvents.tryEmit(UnsolicitedMessage(frame[0], frame)) + } + } + + override suspend fun request(cmd: BleCommand): R = requestMutex.withLock { + val deferred = CompletableDeferred() + // Register the waiter BEFORE writing so a synchronous response from the + // peripheral cannot race ahead of our subscription. + val waiter = SingleWaiter(cmd.expectedResponseOpcode, cmd.correlationByte, deferred) + waiterRef.set(waiter) + var consumed = false + try { + gatt.writeCharacteristic(writeUuid, cmd.encode()) + val payload = deferred.await() + consumed = true + cmd.decode(payload) + } finally { + // compareAndSet: never clobber a successor registered after a disconnect + // already took-and-aborted this waiter. + waiterRef.compareAndSet(waiter, null) + if (!consumed) abandonAndReroute(waiter) + } + } + + override suspend fun requestMultiple(cmd: BleMultiCommand): R = + requestMutex.withLock { + require(cmd.expectedResponseOpcodes.isNotEmpty()) { + "expectedResponseOpcodes must not be empty" + } + val deferred = CompletableDeferred>() + val waiter = MultiWaiter(cmd.expectedResponseOpcodes.toMutableSet(), deferred) + waiterRef.set(waiter) + var consumed = false + try { + gatt.writeCharacteristic(writeUuid, cmd.encode()) + val responses = deferred.await() + consumed = true + cmd.decode(responses) + } finally { + waiterRef.compareAndSet(waiter, null) + if (!consumed) abandonAndReroute(waiter) + } + } + + override fun requestStream(cmd: BleStreamCommand): Flow = flow { + requestMutex.withLock { + val channel = Channel(Channel.UNLIMITED) + // Register BEFORE writing (same race-free ordering as request()). + val waiter = StreamWaiter(cmd.expectedResponseOpcode, cmd::decode, cmd::isTerminal, channel) + waiterRef.set(waiter) + try { + gatt.writeCharacteristic(writeUuid, cmd.encode()) + // Emits every decoded notification; the channel is closed by the + // StreamWaiter on the terminal response, on decode/isTerminal failure + // (rethrown here as the close cause), or on disconnect. + for (item in channel) { + emit(item) + } + } finally { + waiterRef.compareAndSet(waiter, null) + // Always abandon: a no-op after normal completion (channel already + // closed), and after cancellation it flips late frames to unsolicited. + waiter.abandon() + } + } + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/CarelevoBleSession.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/CarelevoBleSession.kt new file mode 100644 index 000000000000..b1974b22d015 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/CarelevoBleSession.kt @@ -0,0 +1,510 @@ +package app.aaps.pump.carelevo.ble + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.pump.carelevo.ble.commands.AlertAlarmSetCommand +import app.aaps.pump.carelevo.ble.commands.AppAuthCommand +import app.aaps.pump.carelevo.ble.commands.BasalProgramCommand +import app.aaps.pump.carelevo.ble.commands.InfusionInfoCommand +import app.aaps.pump.carelevo.ble.commands.InfusionInfoResponse +import app.aaps.pump.carelevo.ble.commands.MacAddressCommand +import app.aaps.pump.carelevo.ble.commands.PatchInfoResponse +import app.aaps.pump.carelevo.ble.commands.SafetyCheckCommand +import app.aaps.pump.carelevo.ble.commands.SafetyCheckResponse +import app.aaps.pump.carelevo.ble.commands.SetTimeForPatchInfoCommand +import app.aaps.pump.carelevo.ble.commands.ThresholdSetupCommand +import app.aaps.pump.carelevo.ble.gatt.BleTransportGattConnection +import app.aaps.pump.carelevo.ble.gatt.GattConnState +import app.aaps.pump.carelevo.ble.gatt.GattEvent +import app.aaps.pump.carelevo.ext.checkSumV2 +import app.aaps.pump.carelevo.ext.convertHexToByteArray +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.joda.time.DateTime +import java.util.UUID +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +/** + * Owns BLE sessions: connect → discover services → enable notifications → run [BleClient] exchanges → close. + * + * Two modes share one code path ([withSession]): + * - **Queue-owned held link** ([openLink]/[requestConnect] … [requestDisconnect]): the AAPS CommandQueue + * holds ONE session open across a whole connect→run-ops→disconnect burst; every op reuses it, so a + * multi-op sequence (e.g. cancel-temp then pump-stop) does NOT re-dial between ops. This is the resting + * therapy path once a patch is activated. See `_docs/CARELEVO_QUEUE_OWNED_LINK.md`. + * - **Transient per-op session** ([transientSession]): open→run→close for pre-activation / pairing / any op + * with no held link. + * + * Either way each session builds a **fresh** [BleTransportGattConnection] + [BleClientImpl] + [CoroutineScope] + * and closes them once: [BleTransportGattConnection.close] is one-shot (it latches `closed` and releases the + * transport's single listener slot), so a closed instance is never reused — [openLink] builds a new one on + * every connect, and [tearDownHeld]/[transientSession] close it exactly once. + * + * A session opens its own GATT; two GATT clients to one patch cause the status-133 collision, so a + * session must not run concurrently with any other link to the patch. The caller (a customCommand) + * is responsible for ensuring no other link is active before invoking a session. + */ +@Singleton +class CarelevoBleSession @Inject constructor( + private val transport: CarelevoBleTransport, + @Named("characterRx") private val writeUuid: UUID, + @Named("characterTx") private val notifyUuid: UUID, + private val aapsLogger: AAPSLogger +) { + + // The new transport is a @Singleton with a SINGLE GATT + single listener slot, so two sessions can + // never physically overlap. This mutex enforces that at the API level: every session serializes, so an + // out-of-band caller (e.g. a bolus cancel fired off the queue worker by cancelAllBoluses) waits for the + // in-flight session to close and release before it opens — never a two-GATT status-133 collision. + private val sessionMutex = Mutex() + + // Test seam: the per-session scope dispatcher. Production always uses IO; tests inject a + // TestDispatcher so the event-router subscription and frame emissions are deterministically ordered + // (the events SharedFlow has no replay, so a frame emitted before the router subscribes is lost — + // impossible against real-radio latencies, but instant fake responses can hit it). + internal var sessionDispatcher: CoroutineDispatcher = Dispatchers.IO + + // Wall-clock of the last session close, for the inter-session settle below. + @Volatile private var lastCloseAtMs = 0L + + /** + * Handler for unsolicited notifications (alarms, stop/basal-restart reports) received while a session + * is open — the [BleClient.unsolicitedEvents] bridge the BLE migration dropped. Set by the pump plugin + * on start, cleared on stop. Invoked on the session's IO scope; the handler MUST NOT start another BLE + * session ([withSession] holds [sessionMutex] for its whole duration, so a nested session self-deadlocks). + */ + @Volatile var unsolicitedHandler: ((UnsolicitedMessage) -> Unit)? = null + + private val _connected = MutableStateFlow(false) + /** + * Queue-owned held-link state: true only while [openLink]'s held link is up — NOT for transient per-op + * sessions ([transientSession]). The coordinator reports this as `Pump.isConnected` post-activation, so + * a transient op must never flip it (else the queue would skip connect() and re-dial per op). + */ + val connected: StateFlow = _connected.asStateFlow() + + private val _lastConnectedAt = MutableStateFlow(0L) + /** Wall-clock (ms) of the last time a session reached CONNECTED — the "last connection" reachability signal. */ + val lastConnectedAt: StateFlow = _lastConnectedAt.asStateFlow() + + // ===== Queue-owned held link (CommandQueue lifecycle) ===== + // The AAPS CommandQueue (QueueWorker) drives connect() → run ops → disconnect(). openLink holds ONE + // session open across a whole queue-busy burst; ops reuse it via withSession (no per-op re-dial), and the + // queue's disconnect() closes it. The new stack dials autoConnect=false with no self-reconnect, so a close + // stays closed and the queue is the sole reconnect driver. See _docs/CARELEVO_QUEUE_OWNED_LINK.md. + + // Test seam (mirrors sessionDispatcher): the dispatcher for queue-driven connect/disconnect kicks. + // Production uses IO; tests inject the shared test dispatcher so requestConnect's async openLink is drivable. + internal var linkDispatcher: CoroutineDispatcher = Dispatchers.IO + /** App-lifetime scope for queue-driven connect/disconnect kicks (never a per-session scope). */ + private val linkScope: CoroutineScope by lazy { CoroutineScope(linkDispatcher + SupervisorJob()) } + + // True while a queue-driven connect attempt is in flight; its compareAndSet also guards against piling + // up overlapping connect launches (feeds `Pump.isConnecting` via the coordinator). + private val _connecting = MutableStateFlow(false) + val isConnecting: StateFlow = _connecting.asStateFlow() + + /** The in-flight connect job, so [requestDisconnect]/stopConnecting can cancel a slow/hung openLink. */ + @Volatile private var connectJob: Job? = null + + private class HeldLink( + val gatt: BleTransportGattConnection, + val client: BleClient, + val scope: CoroutineScope + ) + + /** The currently held queue-owned link, or null when no session is up. */ + @Volatile private var heldLink: HeldLink? = null + + /** Read Infusion Info (0x31 → 0x91) — the periodic status read (reservoir, totals, pump state). */ + suspend fun readInfusionInfo(address: String): InfusionInfoResponse = + withSession(address, "infusion info") { it.request(InfusionInfoCommand()) } + + /** Run any single-response [command] (write or read) on a fresh session. */ + suspend fun runSingle(address: String, command: BleCommand, timeoutMs: Long = READ_TIMEOUT_MS): R = + withSession(address, command::class.simpleName ?: "command", timeoutMs) { it.request(command) } + + /** + * Run the streaming Safety Check (0x12 → 0x72). [onFrame] is invoked for every decoded frame (each + * progress report and the terminal SUCCESS/error) as the pump reports it; the stream completes on the + * terminal frame. Uses a long timeout — the check runs ~100-210 s. + */ + suspend fun runSafetyCheck(address: String, onFrame: (SafetyCheckResponse) -> Unit) = + withSession(address, "safety check", SAFETY_CHECK_TIMEOUT_MS) { client -> + client.requestStream(SafetyCheckCommand()).collect { onFrame(it) } + } + + /** + * Write a full basal program — the initial set (activation, 0x13 → 0x73, [isUpdate] = false) or the + * mid-therapy profile update (0x21 → 0x81, [isUpdate] = true; the V2 update sends the same 3-write + * shape as set, just under the change opcode). A full program is **three sequential + * [BasalProgramCommand]s** (seqNo 0, 1, 2) that MUST share ONE connection, so — unlike [runSingle] — + * all three run inside a single session. [programs] are the per-seqNo segment-speed lists (v2 sends + * speed only). Returns true only if every write reports `resultCode == 0`; short-circuits on the first + * failure (`all` stops early) so a rejected seqNo does not send the rest of a partial program. + */ + suspend fun runBasalProgram(address: String, programs: List>, isUpdate: Boolean = false): Boolean = + withSession(address, "basal program", BASAL_PROGRAM_TIMEOUT_MS) { client -> + programs.withIndex().all { (index, speeds) -> + client.request(BasalProgramCommand(isUpdate = isUpdate, seqNo = index, segmentSpeeds = speeds)).resultCode == RESULT_SUCCESS + } + } + + /** + * Pair a factory-fresh patch, all on ONE session: connect → **create the Android bond** + * (`ensureBond`; the patch requires the bond be created after CONNECTED, before discovery) → + * discover → enable notifications → MAC read (0x3B) → checksum app-auth (0x4B) → set-time→patch-info + * rounds (0x11 → 0x93+0x94, retried while the serial is empty) → alert-alarm mode (0x48) → + * threshold bundle (0x1B). Persistence is NOT done here — the caller feeds the returned + * [PairingResult] to `CarelevoConnectNewPatchUseCase.persistNewPatch`. + */ + suspend fun runPairing(address: String, spec: PairingSpec): PairingResult = + withSession(address, "pairing", PAIRING_TIMEOUT_MS, ensureBond = true) { client -> + val key = (0..255).random() + val macResponse = client.request(MacAddressCommand(key.toByte())) + // The persisted MAC is lowercase, colon-separated. + val colonMac = macResponse.macAddress.lowercase().chunked(2).joinToString(":") + aapsLogger.debug(LTag.PUMPCOMM, "bleSession: pairing MAC=$colonMac") + val checkSumByte = (macResponse.macAddress + macResponse.checkSum).convertHexToByteArray().checkSumV2(key) + val auth = client.request(AppAuthCommand(checkSumByte.toUByte().toInt())) + check(auth.resultCode == RESULT_SUCCESS) { "app auth failed result=${auth.resultCode}" } + + // KNOWN RESIDUAL RISK (accepted): the retry rounds below share ONE live session/collector, + // and BleMultiCommand correlation is opcode-only (the 0x93/0x94 frames carry no round or + // correlation byte the wire protocol could check). A round-1 frame arriving AFTER its own + // 10 s window expired could therefore satisfy one of round-2's expected opcodes and mix + // round-1/round-2 data (e.g. round-1 serial + round-2 firmware). The window is a frame + // delayed >10 s but landing exactly inside the next round — unobserved on real hardware, + // and a fresh session per round would repeat MAC/auth against a factory-fresh patch for a + // worse trade. If it ever bites, the pairing result is rejected by the serial/detail + // result-code checks below or caught at first status read. + var patchInfo: PatchInfoResponse? = null + for (round in 1..PATCH_INFO_ROUND_RETRY_COUNT) { + val info = withTimeoutOrNull(PATCH_INFO_ROUND_TIMEOUT_MS.milliseconds) { + client.requestMultiple(SetTimeForPatchInfoCommand(subId = 0, volume = spec.volume, aidMode = 0, dateTime = DateTime.now())) + } + if (info != null && info.serialResultCode == RESULT_SUCCESS && info.serialNumber.trim().isNotEmpty() && info.detailResultCode == RESULT_SUCCESS) { + patchInfo = info + break + } + aapsLogger.warn( + LTag.PUMPCOMM, + "bleSession: invalid patch info round=$round/$PATCH_INFO_ROUND_RETRY_COUNT " + + "result=${info?.serialResultCode} serial=${info?.serialNumber?.trim()} detailResult=${info?.detailResultCode}" + ) + } + val info = patchInfo ?: throw IllegalStateException("patch info invalid after $PATCH_INFO_ROUND_RETRY_COUNT rounds") + + val alarm = client.request(AlertAlarmSetCommand(ALERT_ALARM_MODE_DEFAULT)) + check(alarm.resultCode == RESULT_SUCCESS) { "alert alarm set failed result=${alarm.resultCode}" } + val threshold = client.request( + ThresholdSetupCommand( + insulinRemainsThreshold = spec.remains, + expiryThreshold = spec.expiry, + maxBasalSpeed = spec.maxBasalSpeed, + maxBolusDose = spec.maxBolusDose, + buzzUse = spec.buzzUse + ) + ) + check(threshold.resultCode == RESULT_SUCCESS) { "threshold setup failed result=${threshold.resultCode}" } + + PairingResult( + address = colonMac, + serialNumber = info.serialNumber.trim(), + firmwareVersion = info.firmwareVersion, + modelName = info.modelName + ) + } + + /** Inputs of the activation threshold/set-time writes. */ + data class PairingSpec( + val volume: Int, + val remains: Int, + val expiry: Int, + val maxBasalSpeed: Double, + val maxBolusDose: Double, + val buzzUse: Boolean + ) + + /** Identity of the freshly paired patch, decoded from the 0x9B + 0x93/0x94 responses. */ + data class PairingResult( + val address: String, + val serialNumber: String, + val firmwareVersion: String, + val modelName: String + ) + + /** + * Fire ONE connect attempt and return — the queue re-polls [connected] until true (matching the + * Dash/Medtrum `connect()` contract). Idempotent: no-ops while a link is already up or a connect is + * in flight. Called from the QueueWorker thread via the coordinator's `connect()`. + */ + fun requestConnect(address: String, reason: String) { + if (_connected.value || !_connecting.compareAndSet(false, true)) return + connectJob = linkScope.launch { + try { + openLink(address, reason) + } catch (c: CancellationException) { + throw c // aborted by requestDisconnect/stopConnecting — not an error + } catch (t: Throwable) { + aapsLogger.error(LTag.PUMPCOMM, "bleSession: openLink failed reason=$reason", t) + } finally { + _connecting.value = false + } + } + } + + /** + * Close the held link (queue `disconnect()` / `stopConnecting()`). Also cancels any in-flight + * [openLink] so a slow/hung connect is aborted promptly rather than briefly opening the link first. + */ + fun requestDisconnect(reason: String) { + connectJob?.cancel() + connectJob = null + linkScope.launch { sessionMutex.withLock { tearDownHeld(reason) } } + } + + /** + * Open the queue-owned held link — one attempt, then leave it up for [withSession] to reuse until the + * queue's `disconnect()` (or an unexpected drop) closes it. Holds [sessionMutex] for the handshake so no + * transient session can race it onto the transport's single GATT. + */ + private suspend fun openLink(address: String, reason: String) = sessionMutex.withLock { + if (heldLink != null) return@withLock + awaitInterSessionSettle() + val mac = address.uppercase() + val scope = CoroutineScope(sessionDispatcher + SupervisorJob()) + val gatt = BleTransportGattConnection(transport, writeUuid, notifyUuid, scope) + val client: BleClient = BleClientImpl(gatt, writeUuid, notifyUuid, scope) + subscribeUnsolicited(scope, client) + try { + withTimeout(CONNECT_TIMEOUT_MS.milliseconds) { open(gatt, mac) } + } catch (t: Throwable) { + // Half-open handshake: release the fresh gatt + scope (one-shot close) so the next queue + // connect() starts clean. + gatt.close() + scope.cancel() + lastCloseAtMs = System.currentTimeMillis() + throw t + } + heldLink = HeldLink(gatt, client, scope) + // Subscribe the drop watcher (UNDISPATCHED → live synchronously) BEFORE marking the link up, so a + // DISCONNECTED racing the handshake can't slip through the replay-0 events flow with no subscriber. + watchForDrop(scope, gatt) + _lastConnectedAt.value = System.currentTimeMillis() + _connected.value = true + aapsLogger.debug(LTag.PUMPCOMM, "bleSession: held link up reason=$reason") + } + + /** + * Flip [connected] false and release the link on an unexpected drop (out-of-range / patch sleep), so + * the queue re-dials on its next command. Runs on the held session scope; the tear-down hops onto + * [linkScope] so it survives that scope's own cancellation. + */ + private fun watchForDrop(scope: CoroutineScope, gatt: BleTransportGattConnection) { + // UNDISPATCHED so the flow subscription registers synchronously on the calling thread (like open()'s + // CONNECTED wait) — the collector is live before this returns, closing the replay-0 lost-event window. + scope.launch(start = CoroutineStart.UNDISPATCHED) { + gatt.events + .filterIsInstance() + .first { it.state == GattConnState.DISCONNECTED } + aapsLogger.debug(LTag.PUMPCOMM, "bleSession: held link dropped") + linkScope.launch { sessionMutex.withLock { tearDownHeld("dropped") } } + } + } + + /** Idempotent teardown of the held link. Caller holds [sessionMutex]. */ + private fun tearDownHeld(reason: String) { + val link = heldLink ?: return + _connected.value = false + link.gatt.close() + link.scope.cancel() + lastCloseAtMs = System.currentTimeMillis() + heldLink = null + aapsLogger.debug(LTag.PUMPCOMM, "bleSession: held link closed reason=$reason") + } + + /** + * Run [block] against a [BleClient]. Reuses the queue-owned held link when one is up (no open/close — + * the queue owns teardown); otherwise falls back to a [transientSession] (open→run→close). [ensureBond] + * (pairing) always forces a fresh transient session. + */ + private suspend fun withSession( + address: String, + label: String, + timeoutMs: Long = READ_TIMEOUT_MS, + ensureBond: Boolean = false, + block: suspend (BleClient) -> R + ): R = + sessionMutex.withLock { + val link = heldLink + if (link != null && !ensureBond) { + aapsLogger.debug(LTag.PUMPCOMM, "bleSession: $label on held link") + try { + withTimeout(timeoutMs.milliseconds) { block(link.client) } + } catch (t: TimeoutCancellationException) { + // Op timed out on the held link → the link is suspect. Drop it so the next queue command + // re-dials a fresh GATT (restores the per-op self-heal the held link removed). + tearDownHeld("op timed out on held link: $label") + throw t + } catch (c: CancellationException) { + throw c // caller cancelled the op (e.g. StopBolus) — the link is fine, leave it up + } catch (t: Throwable) { + // GATT write/disconnect failure → the link is dead. Drop it so the next command re-dials. + tearDownHeld("op failed on held link: $label") + throw t + } + } else { + // A transient session must be the ONLY link on the single-GATT/single-listener transport. If + // a held link is somehow up (e.g. an ensureBond pairing while a prior link is still held), + // close it first to avoid a second concurrent GATT (status-133) and a stolen listener slot. + if (link != null) tearDownHeld("transient session needs the transport: $label") + transientSession(address, label, timeoutMs, ensureBond, block) + } + } + + /** + * Per-op session: open a fresh connection, run [block] against the [BleClient], and close. Used for + * pre-activation / pairing / any op with no held link. Each call gets its own adapter+client+scope — + * see the class KDoc for why (one-shot [BleTransportGattConnection.close]). [ensureBond] (pairing only) + * creates the Android bond after CONNECTED, before discovery — the order the patch requires. Caller + * holds [sessionMutex]. + */ + private suspend fun transientSession( + address: String, + label: String, + timeoutMs: Long, + ensureBond: Boolean, + block: suspend (BleClient) -> R + ): R { + awaitInterSessionSettle() + // BluetoothAdapter.getRemoteDevice requires an UPPERCASE MAC (lowercase throws + // IllegalArgumentException); the stored address is lowercase, so normalize here. + val mac = address.uppercase() + val scope = CoroutineScope(sessionDispatcher + SupervisorJob()) + val gatt = BleTransportGattConnection(transport, writeUuid, notifyUuid, scope) + val client: BleClient = BleClientImpl(gatt, writeUuid, notifyUuid, scope) + subscribeUnsolicited(scope, client) + return try { + // Bound the ENTIRE connect→discover→enable handshake, not just the CONNECTED wait: a lost + // CCCD-write callback (which the transport documents as "surfaced by the caller's withTimeout") + // would otherwise suspend forever HERE while holding sessionMutex, wedging every later + // session op — including a delivery-critical out-of-band bolus cancel. On timeout the finally closes the + // gatt (aborting the pending ack) and releases the mutex. + val openTimeoutMs = if (ensureBond) CONNECT_TIMEOUT_MS + BOND_TIMEOUT_MS else CONNECT_TIMEOUT_MS + withTimeout(openTimeoutMs.milliseconds) { open(gatt, mac, ensureBond) } + // NB: a transient session does NOT touch _connected — that flag is the queue-owned held-link + // signal (see [connected]). It updates lastConnectedAt only, since it is a real connection. + _lastConnectedAt.value = System.currentTimeMillis() + aapsLogger.debug(LTag.PUMPCOMM, "bleSession: reading $label") + withTimeout(timeoutMs.milliseconds) { block(client) } + } finally { + gatt.close() + scope.cancel() + lastCloseAtMs = System.currentTimeMillis() + } + } + + /** Inter-session settle: let the patch fully release the previous link before the next dial (~1 s). */ + private suspend fun awaitInterSessionSettle() { + val sinceCloseMs = System.currentTimeMillis() - lastCloseAtMs + if (sinceCloseMs in 0 until INTER_SESSION_SETTLE_MS) delay((INTER_SESSION_SETTLE_MS - sinceCloseMs).milliseconds) + } + + /** + * Bridge patch-pushed frames (alarms, stop/basal-restart reports) to [unsolicitedHandler] for the life + * of [scope]. Subscribed before open() so a frame during the handshake isn't missed; the scope's + * cancellation tears this collector down with the session/link. + */ + private fun subscribeUnsolicited(scope: CoroutineScope, client: BleClient) { + scope.launch { + client.unsolicitedEvents.collect { msg -> + try { + unsolicitedHandler?.invoke(msg) + } catch (t: Throwable) { + aapsLogger.error(LTag.PUMPCOMM, "unsolicited handler error", t) + } + } + } + } + + private suspend fun open(gatt: BleTransportGattConnection, address: String, ensureBond: Boolean = false) = coroutineScope { + // Subscribe to the CONNECTED event BEFORE calling connect() so the state change cannot race + // ahead of our collector. UNDISPATCHED runs the async body up to the flow subscription + // synchronously, guaranteeing the subscription is live before connect() fires. + val connected = async(start = CoroutineStart.UNDISPATCHED) { + withTimeout(CONNECT_TIMEOUT_MS.milliseconds) { + gatt.events + .filterIsInstance() + .first { it.state == GattConnState.CONNECTED } + } + } + aapsLogger.debug(LTag.PUMPCOMM, "bleSession: connecting to $address") + require(gatt.connect(address)) { "bleSession: connect() refused for $address" } + connected.await() + if (ensureBond && !transport.adapter.isDeviceBonded(address)) { + // The patch requires the bond be created right after STATE_CONNECTED; the transport exposes + // no bond-state callback, so poll isDeviceBonded until the SMP completes. + aapsLogger.debug(LTag.PUMPCOMM, "bleSession: creating bond") + transport.adapter.createBond(address) + withTimeout(BOND_TIMEOUT_MS.milliseconds) { + while (!transport.adapter.isDeviceBonded(address)) delay(BOND_POLL_MS.milliseconds) + } + aapsLogger.debug(LTag.PUMPCOMM, "bleSession: bonded") + } + aapsLogger.debug(LTag.PUMPCOMM, "bleSession: connected; discovering services") + gatt.discoverServices() + gatt.enableNotifications(notifyUuid) + aapsLogger.debug(LTag.PUMPCOMM, "bleSession: notifications enabled") + } + + private companion object { + + const val CONNECT_TIMEOUT_MS = 20_000L + const val READ_TIMEOUT_MS = 15_000L + + // Safety check streams progress for ~100-210 s before the terminal frame; give it headroom. + const val SAFETY_CHECK_TIMEOUT_MS = 250_000L + + // Three sequential basal-program writes on one connection; generous headroom over READ_TIMEOUT_MS. + const val BASAL_PROGRAM_TIMEOUT_MS = 30_000L + private const val RESULT_SUCCESS = 0 + + // Pairing: MAC read + auth + up to 2 patch-info rounds (10 s each) + alarm + threshold; + // bond wait is bounded separately in open(). + const val PAIRING_TIMEOUT_MS = 45_000L + private const val PATCH_INFO_ROUND_RETRY_COUNT = 2 + private const val PATCH_INFO_ROUND_TIMEOUT_MS = 10_000L + private const val BOND_TIMEOUT_MS = 15_000L + private const val BOND_POLL_MS = 250L + + // Activation sends alert-alarm mode 0. + private const val ALERT_ALARM_MODE_DEFAULT = 0 + + // Minimum gap between one session's close and the next session's connect (patch-side settle). + private const val INTER_SESSION_SETTLE_MS = 1000L + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/CarelevoBleTransport.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/CarelevoBleTransport.kt new file mode 100644 index 000000000000..95684b3329d7 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/CarelevoBleTransport.kt @@ -0,0 +1,26 @@ +package app.aaps.pump.carelevo.ble + +import app.aaps.core.interfaces.pump.ble.BleTransport + +/** + * CareLevo-specific extension of the shared fleet [BleTransport]. + * + * CareLevo runs on the shared fleet [BleTransport] (the same abstraction Dana/Equil/Medtrum use); + * the coroutine correlation layer lives in [BleClient] above the transport. + * + * Adds the two fields the fleet impls carry that aren't part of the generic interface: + * - [scanAddress]: MAC filter for [app.aaps.core.interfaces.pump.ble.BleScanner.startScan] + * - [onGattError133]: hook for the status-133 bond-lock workaround + * + * Implemented by [CarelevoBleTransportImpl] (production). A future + * `CarelevoEmulatorBleTransport` in a `:pump:carelevo-emulator` module can implement the same + * interface for hardware-free testing, swapped in via DI exactly like Dana/Equil. + */ +interface CarelevoBleTransport : BleTransport { + + /** MAC address filter for scanning. Set before calling `scanner.startScan()`. */ + var scanAddress: String? + + /** Called when GATT error 133 occurs, before `onConnectionStateChanged(false)`. */ + var onGattError133: (() -> Unit)? +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/CarelevoBleTransportImpl.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/CarelevoBleTransportImpl.kt new file mode 100644 index 000000000000..38d73faf9f96 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/CarelevoBleTransportImpl.kt @@ -0,0 +1,349 @@ +package app.aaps.pump.carelevo.ble + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.BluetoothStatusCodes +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.ParcelUuid +import android.os.SystemClock +import androidx.core.app.ActivityCompat +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.pump.ble.BleAdapter +import app.aaps.core.interfaces.pump.ble.BleGatt +import app.aaps.core.interfaces.pump.ble.BleScanner +import app.aaps.core.interfaces.pump.ble.BleTransportListener +import app.aaps.core.interfaces.pump.ble.PairingState +import app.aaps.core.interfaces.pump.ble.ScannedDevice +import app.aaps.pump.carelevo.config.BleEnvConfig +import app.aaps.pump.carelevo.ext.convertBytesToHex +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Production [CarelevoBleTransport] over Android's `BluetoothGatt`. + * + * Structurally a clone of `EquilBleTransportImpl` — carelevo is the same single-service / + * single-write-characteristic / single-notify-characteristic patch-pump shape, so the fleet + * template maps 1:1. The three sub-interfaces are private inner classes sharing the impl's + * mutable GATT state, and the single [BluetoothGattCallback] fans every event out to one + * [BleTransportListener] (the [app.aaps.pump.carelevo.ble.gatt.BleTransportGattConnection] + * adapter, which bridges into the [BleClient] correlation layer). + * + * UUIDs come from [BleEnvConfig]: service `e1b40001`, write char (rx) `e1b40002`, + * notify char (tx) `e1b40003`, CCCD `2902`. Notifications (not indications), no MTU negotiation. + */ +@SuppressLint("MissingPermission") +@Singleton +class CarelevoBleTransportImpl @Inject constructor( + private val context: Context, + private val aapsLogger: AAPSLogger +) : CarelevoBleTransport { + + private val bluetoothAdapter: BluetoothAdapter? + get() = (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager?)?.adapter + + private var listener: BleTransportListener? = null + private var bluetoothGatt: BluetoothGatt? = null + private var notifyChara: BluetoothGattCharacteristic? = null + private var writeChara: BluetoothGattCharacteristic? = null + + override var scanAddress: String? = null + override var onGattError133: (() -> Unit)? = null + + @Suppress("deprecation", "OVERRIDE_DEPRECATION") + private val gattCallback = object : BluetoothGattCallback() { + + @Synchronized + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + if (status == 133) { + aapsLogger.debug(LTag.PUMPBTCOMM, "GATT error 133, removing bond") + scanAddress?.let { adapter.removeBond(it) } + SystemClock.sleep(50) // Give BT stack time to process bond removal + onGattError133?.invoke() + listener?.onConnectionStateChanged(false) + return + } + listener?.onConnectionStateChanged(newState == BluetoothProfile.STATE_CONNECTED) + } + + @Synchronized + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + if (status != BluetoothGatt.GATT_SUCCESS) { + aapsLogger.debug(LTag.PUMPBTCOMM, "onServicesDiscovered received: $status") + listener?.onServicesDiscovered(false) + return + } + val service = gatt.getService(SERVICE_UUID) + if (service != null) { + notifyChara = service.getCharacteristic(TX_CHAR_UUID) + writeChara = service.getCharacteristic(RX_CHAR_UUID) + } + listener?.onServicesDiscovered(service != null && notifyChara != null && writeChara != null) + } + + override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { + listener?.onCharacteristicWritten() + } + + override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { + listener?.onCharacteristicChanged(characteristic.value) + } + + override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { + val data = characteristic.value + // Log every inbound frame (opcode = first byte) so patch-pushed notifications — alarms and the + // auto-resume report — are visible even though the unsolicited path is not yet consumed. The + // pre-migration driver logged incoming frames here; the transport migration dropped it. + aapsLogger.debug(LTag.PUMPBTCOMM, "onCharacteristicChanged incoming: ${data.convertBytesToHex()}") + listener?.onCharacteristicChanged(data) + } + + @Synchronized + override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { + aapsLogger.debug(LTag.PUMPBTCOMM, "onDescriptorWrite received: $status") + if (status == BluetoothGatt.GATT_SUCCESS) { + listener?.onDescriptorWritten() + } + } + } + + // --- BleTransport --- + + override val adapter: BleAdapter = CarelevoAdapterImpl() + override val scanner: BleScanner = CarelevoScannerImpl() + override val gatt: BleGatt = CarelevoGattImpl() + + private val _pairingState = MutableStateFlow(PairingState()) + override val pairingState: StateFlow = _pairingState + + override fun updatePairingState(state: PairingState) { + _pairingState.value = state + } + + override fun setListener(listener: BleTransportListener?) { + val existing = this.listener + if (existing != null && listener != null && existing !== listener) { + // This @Singleton holds a single listener slot; a new adapter registering before the + // previous one closed would silently orphan it (its in-flight ops would hang). This is a + // defensive guard against that overlap. + aapsLogger.warn(LTag.PUMPBTCOMM, "setListener overwriting an active listener; previous connection not closed?") + } + this.listener = listener + } + + // --- BleAdapter --- + + private inner class CarelevoAdapterImpl : BleAdapter { + + override fun enable() { + // not used by CareLevo + } + + override fun getDeviceName(address: String): String? { + if (!hasPermission(Manifest.permission.BLUETOOTH_CONNECT)) return null + return bluetoothAdapter?.getRemoteDevice(address)?.name + } + + override fun isDeviceBonded(address: String): Boolean { + if (!hasPermission(Manifest.permission.BLUETOOTH_CONNECT)) return false + val device = bluetoothAdapter?.getRemoteDevice(address) ?: return false + return device.bondState == BluetoothDevice.BOND_BONDED + } + + override fun createBond(address: String): Boolean { + if (!hasPermission(Manifest.permission.BLUETOOTH_CONNECT)) return false + val device = bluetoothAdapter?.getRemoteDevice(address) ?: return false + return device.createBond() + } + + override fun removeBond(address: String) { + if (!hasPermission(Manifest.permission.BLUETOOTH_CONNECT)) return + try { + val pairedDevices = bluetoothAdapter?.bondedDevices ?: return + for (device in pairedDevices) { + if (device.address == address) { + val method = device.javaClass.getMethod("removeBond") + method.invoke(device) + } + } + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "Error removing bond", e) + } + } + } + + // --- BleScanner --- + + private inner class CarelevoScannerImpl : BleScanner { + + private var scanCallback: ScanCallback? = null + private val _scannedDevices = MutableSharedFlow(extraBufferCapacity = 10) + override val scannedDevices: SharedFlow = _scannedDevices + + override fun startScan() { + if (!hasPermission(Manifest.permission.BLUETOOTH_SCAN)) return + + scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val name = result.device?.name + if (name?.isNotEmpty() == true) { + _scannedDevices.tryEmit( + ScannedDevice( + name = name, + address = result.device.address, + scanRecordBytes = result.scanRecord?.bytes, + rssi = result.rssi + ) + ) + } + } + } + + try { + val mac = scanAddress + val filters = if (mac.isNullOrEmpty()) { + // Discovery scan: filter by service UUID (pairing wizard) + listOf( + ScanFilter.Builder() + .setServiceUuid(ParcelUuid(SERVICE_UUID)) + .build() + ) + } else { + // Reconnection scan: filter by MAC address + listOf( + ScanFilter.Builder() + .setDeviceAddress(mac) + .build() + ) + } + val settings = ScanSettings.Builder() + .setReportDelay(0) + .build() + bluetoothAdapter?.bluetoothLeScanner?.startScan(filters, settings, scanCallback) + } catch (_: IllegalStateException) { + // BT not on + } + } + + override fun stopScan() { + try { + scanCallback?.let { bluetoothAdapter?.bluetoothLeScanner?.stopScan(it) } + } catch (_: IllegalStateException) { + // BT not on + } + scanCallback = null + } + } + + // --- BleGatt --- + + private inner class CarelevoGattImpl : BleGatt { + + override fun connect(address: String): Boolean { + if (!hasPermission(Manifest.permission.BLUETOOTH_CONNECT)) return false + val device = bluetoothAdapter?.getRemoteDevice(address) ?: return false + // Release any previous client before opening a new one. Android caps concurrent GATT + // client interfaces; leaking them (connectGatt without close) leads to status 133 and + // an eventual inability to connect at all. + bluetoothGatt?.close() + bluetoothGatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + return bluetoothGatt != null + } + + override fun disconnect() { + bluetoothGatt?.disconnect() + } + + override fun close() { + bluetoothGatt?.close() + bluetoothGatt = null + notifyChara = null + writeChara = null + } + + override fun discoverServices() { + bluetoothGatt?.discoverServices() + } + + override fun findCharacteristics(): Boolean { + return notifyChara != null && writeChara != null + } + + @Suppress("deprecation") + override fun enableNotifications() { + val chara = notifyChara ?: return + val gatt = bluetoothGatt ?: return + val result = gatt.setCharacteristicNotification(chara, true) + if (result) { + val descriptor = chara.getDescriptor(CCCD_UUID) ?: return + val writeStarted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) == BluetoothStatusCodes.SUCCESS + } else { + descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + gatt.writeDescriptor(descriptor) + } + if (!writeStarted) { + aapsLogger.debug(LTag.PUMPBTCOMM, "enableNotifications: writeDescriptor could not start") + } + } + } + + @Suppress("deprecation") + override fun writeCharacteristic(data: ByteArray) { + val chara = writeChara + val gatt = bluetoothGatt + if (chara == null || gatt == null) { + aapsLogger.debug(LTag.PUMPBTCOMM, "writeCharacteristic: not connected") + listener?.onConnectionStateChanged(false) + return + } + // setValue-then-write is NOT atomic on the shared characteristic object; it is safe only + // because BleTransportGattConnection.gattMutex serializes all suspend GATT ops. Do not + // decouple this transport from that serialization without adding one here. + val writeStarted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + gatt.writeCharacteristic(chara, data, chara.writeType) == BluetoothStatusCodes.SUCCESS + } else { + chara.value = data + gatt.writeCharacteristic(chara) + } + if (!writeStarted) { + aapsLogger.debug(LTag.PUMPBTCOMM, "writeCharacteristic: writeCharacteristic could not start") + } + } + + override fun requestConnectionPriority(priority: Int) { + bluetoothGatt?.requestConnectionPriority(priority) + } + } + + private fun hasPermission(permission: String): Boolean = + ActivityCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + + private companion object { + + private val SERVICE_UUID: UUID = UUID.fromString(BleEnvConfig.BLE_SERVICE_UUID) + private val TX_CHAR_UUID: UUID = UUID.fromString(BleEnvConfig.BLE_TX_CHAR_UUID) + private val RX_CHAR_UUID: UUID = UUID.fromString(BleEnvConfig.BLE_RX_CHAR_UUID) + private val CCCD_UUID: UUID = UUID.fromString(BleEnvConfig.BLE_CCC_DESCRIPTOR) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/AdditionalPrimingCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/AdditionalPrimingCommand.kt new file mode 100644 index 000000000000..a6b44b3d684f --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/AdditionalPrimingCommand.kt @@ -0,0 +1,34 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand + +/** + * `CMD_ADD_PRIMING_REQ` (0x1D) → `CMD_ADD_PRIMING_RES` (0x7D). Requests an additional priming pulse on the + * patch (medium criticality) — used to top up priming when the initial prime was insufficient. + * + * Request wire format (1 byte): `[0] 0x1D` — opcode alone, no arguments to encode or range-check. + * + * Response (0x7D): `[0] 0x7D, [1] resultCode` → [SimpleResultResponse] (`resultCode` 0 = SUCCESS). + * + * Note: the pump later emits an unsolicited `0x98` PulseFinish notification when priming completes; that is a + * separate message and is **not** part of this request/response pair. + */ +class AdditionalPrimingCommand : BleCommand { + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = byteArrayOf(requestOpcode) + + override fun decode(responsePayload: ByteArray): SimpleResultResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return SimpleResultResponse(responsePayload.u(1)) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x1D + const val RESPONSE_OPCODE: Byte = 0x7D + private const val MIN_RESPONSE_LENGTH = 2 + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/AlarmClearCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/AlarmClearCommand.kt new file mode 100644 index 000000000000..6eb03cc4d9b5 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/AlarmClearCommand.kt @@ -0,0 +1,55 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand +import app.aaps.pump.carelevo.ble.BleResponse + +/** + * `CMD_ALARM_CLEAR_REQ` (0x47) → `CMD_ALARM_CLEAR_RES` (0xA7). Clears a raised patch alarm. + * Criticality: medium. + * + * Request wire format (3 bytes): `[0] 0x47, [1] alarmType, [2] cause`. + * - `alarmType` — raw byte, **no range check**, so values > 0x7F pass straight through as the + * two's-complement byte. + * - `cause` — range-checked `0..100`. + * + * Response (0xA7): `[0] 0xA7, [1] subId, [2] cause, [3] resultCode`. `resultCode` 0 = SUCCESS; + * consumers map it. + */ +class AlarmClearCommand( + private val alarmType: Int, + private val cause: Int +) : BleCommand { + + init { + require(cause in CAUSE_RANGE) { "cause $cause out of range $CAUSE_RANGE" } + } + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = byteArrayOf(requestOpcode, alarmType.toByte(), cause.toByte()) + + override fun decode(responsePayload: ByteArray): AlarmClearResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return AlarmClearResponse( + subId = responsePayload.u(1), + cause = responsePayload.u(2), + resultCode = responsePayload.u(3) + ) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x47 + const val RESPONSE_OPCODE: Byte = 0xA7.toByte() + private val CAUSE_RANGE = 0..100 + private const val MIN_RESPONSE_LENGTH = 4 + } +} + +/** Decoded response from [AlarmClearCommand]: echoed [subId] + [cause], plus pump [resultCode] (0 = SUCCESS). */ +data class AlarmClearResponse( + val subId: Int, + val cause: Int, + val resultCode: Int +) : BleResponse diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/AlertAlarmSetCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/AlertAlarmSetCommand.kt new file mode 100644 index 000000000000..0921b8c1fb67 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/AlertAlarmSetCommand.kt @@ -0,0 +1,36 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand + +/** + * `CMD_ALERT_ALARM_SET_REQ` (0x48) → `CMD_ALERT_ALARM_SET_RES` (0xA8). Sets the patch alert/alarm mode. + * Medium criticality. + * + * Request wire format (2 bytes): `[0] 0x48, [1] mode` — [mode] is written as a **raw byte**. + * + * **Quirk — no range validation:** [mode] is not range-checked; it is emitted as `mode.toByte()` + * verbatim, so values > 0x7F wrap on the wire. + * + * Response (0xA8): `[0] 0xA8, [1] resultCode` → [SimpleResultResponse] (0 = SUCCESS). + */ +class AlertAlarmSetCommand( + private val mode: Int +) : BleCommand { + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = byteArrayOf(requestOpcode, mode.toByte()) + + override fun decode(responsePayload: ByteArray): SimpleResultResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return SimpleResultResponse(responsePayload.u(1)) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x48 + const val RESPONSE_OPCODE: Byte = 0xA8.toByte() + private const val MIN_RESPONSE_LENGTH = 2 + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/AppAuthCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/AppAuthCommand.kt new file mode 100644 index 000000000000..e841c2da5e9b --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/AppAuthCommand.kt @@ -0,0 +1,46 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand + +/** + * `CMD_APP_AUTH_IND` (0x4B) → `CMD_APP_AUTH_ACK` (0xBB). The activation app-auth handshake: the app sends its + * auth [key] and the patch acknowledges with a result code. + * + * Request wire format (2 bytes): `[0] 0x4B, [1] key` — [key] is written as a **raw byte**, with no + * scaling/inversion applied. + * + * **Opcode quirk:** the request opcode is 0x4B, but the reply comes back on `CMD_APP_AUTH_ACK` = **0xBB** + * (not 0x4B, and not the neighbouring `CMD_APP_AUTH_KEY_ACK` = 0xBA), which is why [expectedResponseOpcode] + * is 0xBB rather than echoing the request opcode. + * + * Response (0xBB): `[0] 0xBB, [1] result` → [SimpleResultResponse] (0 = SUCCESS). Only the wire [result] + * byte is carried. + */ +class AppAuthCommand( + private val key: Int +) : BleCommand { + + init { + require(key in KEY_RANGE) { "key $key out of range $KEY_RANGE" } + } + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = byteArrayOf(requestOpcode, key.toByte()) + + override fun decode(responsePayload: ByteArray): SimpleResultResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return SimpleResultResponse(responsePayload.u(1)) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x4B + const val RESPONSE_OPCODE: Byte = 0xBB.toByte() + + // key is written as a raw byte; accept the full unsigned-byte range. + private val KEY_RANGE = 0..255 + private const val MIN_RESPONSE_LENGTH = 2 + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/BasalProgramCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/BasalProgramCommand.kt new file mode 100644 index 000000000000..02cfca5ac69e --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/BasalProgramCommand.kt @@ -0,0 +1,50 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand + +/** + * Basal-program write (v2 encoding). One command class for both: + * - [isUpdate] = `false`: `CMD_BASAL_PROGRAM_REQ1` (0x13) → `RES1` (0x73) — set the initial program (activation) + * - [isUpdate] = `true`: `CMD_BASAL_CHANGE_REQ1` (0x21) → `RES1` (0x81) — update an existing program + * + * A full basal program is sent as **three sequential [BasalProgramCommand]s** ([seqNo] 0, 1, 2) — that + * orchestration lives in the caller, not here (this is NOT a multi-response command; each write gets its + * own single response). + * + * Request wire format: `[0] opcode, [1] seqNo, [2..] segments` where each segment is `[speedInt, speedCenti]` + * (v2 `BasalProgramToByteV2` = `encodeUnitCenti(injectSpeed)`, HALF_UP; no hour/min bytes in v2). + * Response: `[0] opcode, [1] resultCode` → [SimpleResultResponse]. + */ +class BasalProgramCommand( + private val isUpdate: Boolean, + private val seqNo: Int, + private val segmentSpeeds: List +) : BleCommand { + + init { + require(seqNo in SEQ_NO_RANGE) { "seqNo out of range $SEQ_NO_RANGE" } + require(segmentSpeeds.isNotEmpty()) { "segmentSpeeds must not be empty" } + } + + override val requestOpcode: Byte = if (isUpdate) UPDATE_REQUEST_OPCODE else SET_REQUEST_OPCODE + override val expectedResponseOpcode: Byte = if (isUpdate) UPDATE_RESPONSE_OPCODE else SET_RESPONSE_OPCODE + + override fun encode(): ByteArray = + byteArrayOf(requestOpcode, seqNo.toByte()) + + segmentSpeeds.flatMap { encodeUnitCenti(it).asList() }.toByteArray() + + override fun decode(responsePayload: ByteArray): SimpleResultResponse { + requireResponseFrame(responsePayload, expectedResponseOpcode, MIN_RESPONSE_LENGTH) + return SimpleResultResponse(responsePayload.u(1)) + } + + companion object { + + const val SET_REQUEST_OPCODE: Byte = 0x13 + const val SET_RESPONSE_OPCODE: Byte = 0x73 + const val UPDATE_REQUEST_OPCODE: Byte = 0x21 + const val UPDATE_RESPONSE_OPCODE: Byte = 0x81.toByte() + private val SEQ_NO_RANGE = 0..2 + private const val MIN_RESPONSE_LENGTH = 2 + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/BolusCancelCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/BolusCancelCommand.kt new file mode 100644 index 000000000000..d647884619d7 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/BolusCancelCommand.kt @@ -0,0 +1,51 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand +import app.aaps.pump.carelevo.ble.BleResponse + +/** + * `CMD_BOLUS_CANCEL_REQ` (0x2C) → `CMD_BOLUS_CANCEL_RES` (0x8C). Cancels the running immediate bolus. + * Criticality: high (delivery command). + * + * Request wire format (1 byte): `[0] 0x2C` — opcode only, no arguments. + * + * Response wire format (4 bytes): + * ``` + * [0] 0x8C opcode + * [1] resultCode + * [2..3] infusedAmount = [2] + [3]/100.0 (U delivered before the cancel took effect) + * ``` + */ +class BolusCancelCommand : BleCommand { + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = byteArrayOf(requestOpcode) + + override fun decode(responsePayload: ByteArray): BolusCancelResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return BolusCancelResponse( + resultCode = responsePayload.u(1), + infusedAmount = responsePayload.u(2) + responsePayload.u(3) / CENTI + ) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x2C + const val RESPONSE_OPCODE: Byte = 0x8C.toByte() + + private const val MIN_RESPONSE_LENGTH = 4 + private const val CENTI = 100.0 + } +} + +/** + * Decoded response from [BolusCancelCommand]: pump [resultCode] (0 = SUCCESS) and the [infusedAmount] + * (U) delivered before the cancel took effect. + */ +data class BolusCancelResponse( + val resultCode: Int, + val infusedAmount: Double +) : BleResponse diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/BuzzModeCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/BuzzModeCommand.kt new file mode 100644 index 000000000000..b44ec1dd40ad --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/BuzzModeCommand.kt @@ -0,0 +1,35 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand + +/** + * `CMD_BUZZ_CHANGE_REQ` (0x18) → `CMD_BUZZ_CHANGE_RES` (0x78). Turns the patch buzzer reminder on/off. + * + * Request wire format (2 bytes): `[0] 0x18, [1] flag`. **Inverted encoding:** `use=true → 0x01` and + * `use=false → 0x00` (the opposite of the naive mapping). + * + * Response (0x78): `[0] 0x78, [1] resultCode` → [SimpleResultResponse]. + */ +class BuzzModeCommand( + private val use: Boolean +) : BleCommand { + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = byteArrayOf(requestOpcode, if (use) FLAG_ON else FLAG_OFF) + + override fun decode(responsePayload: ByteArray): SimpleResultResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return SimpleResultResponse(responsePayload.u(1)) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x18 + const val RESPONSE_OPCODE: Byte = 0x78 + private const val FLAG_ON: Byte = 0x01 // use=true → 0x01 (inverted encoding) + private const val FLAG_OFF: Byte = 0x00 // use=false → 0x00 + private const val MIN_RESPONSE_LENGTH = 2 + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/CommandCodec.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/CommandCodec.kt new file mode 100644 index 000000000000..e16c9f44e83a --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/CommandCodec.kt @@ -0,0 +1,42 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleResponse +import java.math.BigDecimal +import java.math.RoundingMode +import kotlin.math.roundToInt + +/** + * Shared wire encode/decode helpers for the CareLevo [app.aaps.pump.carelevo.ble.BleCommand]s. + */ + +/** Unsigned byte at [index] as Int. */ +internal fun ByteArray.u(index: Int): Int = this[index].toUByte().toInt() + +/** + * Encode a unit value as 2 bytes `[intPart, centiPart]`, HALF_UP to 2 dp. Range validation stays at + * the command (min/max belongs to the specific op). + */ +internal fun encodeUnitCenti(value: Double): ByteArray { + // valueOf (canonical decimal via Double.toString), NOT BigDecimal(double) (exact binary + // expansion) — the binary form can sit epsilon-below a .xx5 boundary and flip HALF_UP. + val rounded = BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).toDouble() + val whole = rounded.toInt() + val centi = ((rounded - whole) * 100).roundToInt() + return byteArrayOf(whole.toByte(), centi.toByte()) +} + +/** Fail fast unless [payload] starts with [opcode] and is at least [minLength] bytes. */ +internal fun requireResponseFrame(payload: ByteArray, opcode: Byte, minLength: Int) { + require(payload.isNotEmpty() && payload[0] == opcode) { + "expected opcode 0x${"%02X".format(opcode)}, got " + + (payload.getOrNull(0)?.let { "0x${"%02X".format(it)}" } ?: "empty") + } + require(payload.size >= minLength) { "response too short: ${payload.size} < $minLength" } +} + +/** + * Response carrying only the pump result code (`data[1]`) — the common 2-byte `cmd,result` reply used by + * most write acknowledgements (buzz-mode, cancels, basal-program writes, …). `resultCode` 0 = SUCCESS; + * consumers map it. + */ +data class SimpleResultResponse(val resultCode: Int) : BleResponse diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/ExtendBolusCancelCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/ExtendBolusCancelCommand.kt new file mode 100644 index 000000000000..2f573faff500 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/ExtendBolusCancelCommand.kt @@ -0,0 +1,48 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand +import app.aaps.pump.carelevo.ble.BleResponse + +/** + * `CMD_EXTEND_BOLUS_CANCEL_REQ` (0x29) → `CMD_EXTEND_BOLUS_CANCEL_RES` (0x89). Cancels a running + * extended-bolus infusion program. High criticality (delivery-affecting). Single-response [BleCommand]. + * + * Request wire format (1 byte): `[0] 0x29` — opcode only, no arguments. + * + * Response wire format (4 bytes): + * ``` + * [0] 0x89 opcode + * [1] resultCode 0 = SUCCESS + * [2..3] infusedAmount = [2] + [3]/100.0 (U already delivered before the cancel) + * ``` + */ +class ExtendBolusCancelCommand : BleCommand { + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = byteArrayOf(requestOpcode) + + override fun decode(responsePayload: ByteArray): ExtendBolusCancelResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return ExtendBolusCancelResponse( + resultCode = responsePayload.u(1), + infusedAmount = responsePayload.u(2) + responsePayload.u(3) / CENTI + ) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x29 + const val RESPONSE_OPCODE: Byte = 0x89.toByte() + + private const val MIN_RESPONSE_LENGTH = 4 + private const val CENTI = 100.0 + } +} + +/** Decoded response from [ExtendBolusCancelCommand]: pump [resultCode] (0 = SUCCESS) + [infusedAmount] (U). */ +data class ExtendBolusCancelResponse( + val resultCode: Int, + val infusedAmount: Double +) : BleResponse diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/ExtendBolusCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/ExtendBolusCommand.kt new file mode 100644 index 000000000000..12a8222195ed --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/ExtendBolusCommand.kt @@ -0,0 +1,69 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand +import app.aaps.pump.carelevo.ble.BleResponse + +/** + * `CMD_EXTENDED_BOLUS_REQ` (0x25) → `CMD_EXTENDED_BOLUS_RES` (0x85). **Safety-critical delivery** — + * starts an extended (dual-wave) bolus: an immediate dose up-front plus an extended portion delivered + * at [extendedSpeed] over [hour]h[min]m. + * + * Request wire format (7 bytes): `[0] 0x25, [1..2] immediateDose = [intPart, centiPart], + * [3..4] extendedSpeed = [intPart, centiPart], [5] hour, [6] min`. + * + * **No dose range validation:** [immediateDose]/[extendedSpeed] are NOT range-checked; only [hour] + * (0..24) and [min] (0..59) are validated. + * + * Response (0x85): `[0] 0x85, [1] resultCode, [2..3] expectedTime = [2]*60 + [3]` (seconds) → + * [ExtendBolusResponse]. + * + * (The async 0x9C extended-bolus delay report is a separate unsolicited message — not this response.) + */ +class ExtendBolusCommand( + private val immediateDose: Double, + private val extendedSpeed: Double, + private val hour: Int, + private val min: Int +) : BleCommand { + + init { + require(hour in HOUR_RANGE) { "hour $hour out of range $HOUR_RANGE" } + require(min in MIN_RANGE) { "min $min out of range $MIN_RANGE" } + } + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = + byteArrayOf(requestOpcode) + + encodeUnitCenti(immediateDose) + + encodeUnitCenti(extendedSpeed) + + byteArrayOf(hour.toByte(), min.toByte()) + + override fun decode(responsePayload: ByteArray): ExtendBolusResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return ExtendBolusResponse( + resultCode = responsePayload.u(1), + expectedTimeSeconds = responsePayload.u(2) * SECONDS_PER_MINUTE + responsePayload.u(3) + ) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x25 + const val RESPONSE_OPCODE: Byte = 0x85.toByte() + private val HOUR_RANGE = 0..24 + private val MIN_RANGE = 0..59 + private const val MIN_RESPONSE_LENGTH = 4 + private const val SECONDS_PER_MINUTE = 60 + } +} + +/** + * Decoded response from [ExtendBolusCommand]: pump [resultCode] (0 = SUCCESS) and [expectedTimeSeconds], + * the pump-reported expected total delivery time in seconds (`[2]*60 + [3]`). + */ +data class ExtendBolusResponse( + val resultCode: Int, + val expectedTimeSeconds: Int +) : BleResponse diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/ImmediateBolusCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/ImmediateBolusCommand.kt new file mode 100644 index 000000000000..dd9a8e49ce9a --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/ImmediateBolusCommand.kt @@ -0,0 +1,122 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand +import app.aaps.pump.carelevo.ble.BleResponse +import java.math.BigDecimal +import java.math.RoundingMode +import kotlin.math.roundToInt + +/** + * `CMD_IMMED_BOLUS_REQ` (0x24) → `CMD_IMMED_BOLUS_RES` (0x84). + * + * **Safety-critical.** Delivers an immediate bolus. The [actionId] is echoed by the + * pump at byte 1 of the response and is used by [app.aaps.pump.carelevo.ble.BleClientImpl] + * as a second-level correlation guard on top of the opcode match — a response with + * the wrong actionId is rejected rather than accepted, which protects against a stale + * or mis-routed BOLUS_RES from a previous request being interpreted as the answer to + * this one. + * + * Request wire format (4 bytes): + * ``` + * [0] 0x24 opcode + * [1] actionId (1..255) caller-chosen, pump echoes it back in the response + * [2] volumeWholeUnits integer part of volume (U, truncated) + * [3] volumeCentiUnits fractional part × 100, rounded HALF_UP to 2 dp + * ``` + * + * Example: `ImmediateBolusCommand(actionId = 42, volume = 2.5).encode()` → + * `[0x24, 0x2A, 0x02, 0x32]` (42 = 0x2A, 50 centi-units = 0x32). + * + * Response wire format (≥ 8 bytes): + * ``` + * [0] 0x84 opcode + * [1] actionId echoed from the request — used for correlation + * [2] resultCode pump-specific result code; 0 = SUCCESS + * [3] expectedMinutes minutes portion of expected completion time + * [4] expectedSeconds seconds portion (combined seconds = [3]*60 + [4]) + * [5..7] remainsWholeU remaining reservoir units = [5]*100.0 + [6] + [7]/100.0 + * ``` + */ +class ImmediateBolusCommand( + /** Caller-chosen action identifier in 1..255. Pump echoes this back at byte 1 of the response. */ + private val actionId: Int, + /** Bolus amount in units. Must be > 0. Encoded with 2-decimal precision. */ + private val volume: Double +) : BleCommand { + + init { + require(actionId in ACTION_ID_MIN..ACTION_ID_MAX) { + "actionId must be in $ACTION_ID_MIN..$ACTION_ID_MAX, got $actionId" + } + require(volume > 0.0) { "volume must be > 0, got $volume" } + } + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + override val correlationByte: Byte = actionId.toByte() + + override fun encode(): ByteArray { + // valueOf (canonical decimal), not BigDecimal(double) — see encodeUnitCenti in CommandCodec. + val rounded = BigDecimal.valueOf(volume).setScale(2, RoundingMode.HALF_UP).toDouble() + val wholeUnits = rounded.toInt() + val centiUnits = ((rounded - wholeUnits) * 100).roundToInt() + return byteArrayOf( + requestOpcode, + actionId.toByte(), + wholeUnits.toByte(), + centiUnits.toByte() + ) + } + + override fun decode(responsePayload: ByteArray): ImmediateBolusResponse { + require(responsePayload.isNotEmpty() && responsePayload[0] == expectedResponseOpcode) { + "expected opcode 0x${"%02X".format(expectedResponseOpcode)}, got " + + (responsePayload.getOrNull(0)?.let { "0x${"%02X".format(it)}" } ?: "empty") + } + require(responsePayload.size >= MIN_RESPONSE_LENGTH) { + "response too short: ${responsePayload.size} bytes, need at least $MIN_RESPONSE_LENGTH" + } + + val actionIdEcho = responsePayload[1].toUByte().toInt() + val resultCode = responsePayload[2].toUByte().toInt() + val expectedSeconds = + responsePayload[3].toUByte().toInt() * SECONDS_PER_MINUTE + responsePayload[4].toUByte().toInt() + val remainingUnits = responsePayload[5].toUByte().toInt() * REMAINS_HUNDREDS_SCALE + + responsePayload[6].toUByte().toInt() + + responsePayload[7].toUByte().toInt() / REMAINS_FRAC_SCALE + + return ImmediateBolusResponse( + actionId = actionIdEcho, + resultCode = resultCode, + expectedCompletionSeconds = expectedSeconds, + remainingReservoirUnits = remainingUnits + ) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x24 + const val RESPONSE_OPCODE: Byte = 0x84.toByte() + + const val ACTION_ID_MIN = 1 + const val ACTION_ID_MAX = 255 + + private const val MIN_RESPONSE_LENGTH = 8 + private const val SECONDS_PER_MINUTE = 60 + private const val REMAINS_HUNDREDS_SCALE = 100.0 + private const val REMAINS_FRAC_SCALE = 100.0 + } +} + +/** + * Decoded response from [ImmediateBolusCommand]. + * + * [resultCode] is the raw pump-protocol result byte; consumers map it to a domain + * enum (`0 = SUCCESS`). + */ +data class ImmediateBolusResponse( + val actionId: Int, + val resultCode: Int, + val expectedCompletionSeconds: Int, + val remainingReservoirUnits: Double +) : BleResponse diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/InfusionInfoCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/InfusionInfoCommand.kt new file mode 100644 index 000000000000..342f093d962d --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/InfusionInfoCommand.kt @@ -0,0 +1,94 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand +import app.aaps.pump.carelevo.ble.BleResponse + +/** + * `CMD_INFUSION_INFO_REQ` (0x31) → `CMD_INFUSION_INFO_RPT` (0x91). The periodic status read: reservoir, + * running time, infused basal/bolus totals, pump state + mode. Single-response [BleCommand]. + * + * Request wire format (2 bytes): `[0] 0x31, [1] inquiryType` (0 = by-request). + * + * Response wire format (20 bytes): + * ``` + * [0] 0x91 opcode + * [1] subId inquiry source code (not persisted) + * [2..3] runningMinutes = [2]*60 + [3] + * [4..6] insulinRemaining = [4]*100 + [5] + [6]/100.0 (U; the ×100 hundreds byte is a protocol quirk) + * [7..8] basal total = [7] + [8]/100.0 (U) + * [9..10] bolus total = [9] + [10]/100.0 (U) + * [11] pumpState (raw) + * [12] mode (raw) + * [13..14] infuseSetMinutes = [13]*60 + [14] (not persisted) + * [15..16] currentInfusedProgramVolume = [15] + [16]/100.0 (not persisted) + * [17..19] realInfusedTime = ([17]*60 + [18])*60 + [19] (seconds; not persisted) + * ``` + * Decodes RAW values (unsigned). The pumpState/mode normalization to the persisted codes (the + * int→enum→int roundtrip) happens at the state-apply layer (`CarelevoPatch.applyInfusionInfoReport`), + * keeping this command a pure wire decoder. + */ +class InfusionInfoCommand( + private val inquiryType: Int = DEFAULT_INQUIRY_TYPE +) : BleCommand { + + init { + require(inquiryType in 0..1) { "inquiryType must be 0 or 1, got $inquiryType" } + } + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = byteArrayOf(requestOpcode, inquiryType.toByte()) + + override fun decode(responsePayload: ByteArray): InfusionInfoResponse { + require(responsePayload.isNotEmpty() && responsePayload[0] == expectedResponseOpcode) { + "expected opcode 0x${"%02X".format(expectedResponseOpcode)}, got " + + (responsePayload.getOrNull(0)?.let { "0x${"%02X".format(it)}" } ?: "empty") + } + require(responsePayload.size >= MIN_RESPONSE_LENGTH) { + "response too short: ${responsePayload.size} < $MIN_RESPONSE_LENGTH" + } + fun u(i: Int) = responsePayload[i].toUByte().toInt() + return InfusionInfoResponse( + subId = u(1), + runningMinutes = u(2) * MINUTES_PER_HOUR + u(3), + insulinRemaining = u(4) * HUNDRED + u(5) + u(6) / CENTI, + infusedTotalBasalAmount = u(7) + u(8) / CENTI, + infusedTotalBolusAmount = u(9) + u(10) / CENTI, + pumpStateRaw = u(11), + modeRaw = u(12), + currentInfusedProgramVolume = u(15) + u(16) / CENTI, + realInfusedTime = (u(17) * MINUTES_PER_HOUR + u(18)) * SECONDS_PER_MINUTE + u(19) + ) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x31 + const val RESPONSE_OPCODE: Byte = 0x91.toByte() + const val DEFAULT_INQUIRY_TYPE = 0 + + private const val MIN_RESPONSE_LENGTH = 20 + private const val MINUTES_PER_HOUR = 60 + private const val SECONDS_PER_MINUTE = 60 + private const val HUNDRED = 100 + private const val CENTI = 100.0 + } +} + +/** + * Decoded response from [InfusionInfoCommand] — RAW wire values. [pumpStateRaw]/[modeRaw] are the + * unnormalized bytes; `CarelevoPatch.applyInfusionInfoReport` applies the enum roundtrip before + * persisting. [currentInfusedProgramVolume]/[realInfusedTime]/[subId] are decoded but not persisted. + */ +data class InfusionInfoResponse( + val subId: Int, + val runningMinutes: Int, + val insulinRemaining: Double, + val infusedTotalBasalAmount: Double, + val infusedTotalBolusAmount: Double, + val pumpStateRaw: Int, + val modeRaw: Int, + val currentInfusedProgramVolume: Double, + val realInfusedTime: Int +) : BleResponse diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/InfusionThresholdCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/InfusionThresholdCommand.kt new file mode 100644 index 000000000000..06c962d4d2e3 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/InfusionThresholdCommand.kt @@ -0,0 +1,54 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand +import app.aaps.pump.carelevo.ble.BleResponse + +/** + * `CMD_INFUSION_THRESHOLD_REQ` (0x17) → `CMD_INFUSION_THRESHOLD_RES` (0x77). Sets an infusion limit — + * one command class for both consumers, parameterized by [isMaxVolume]: + * - `false` = max basal **speed** (U/h), range 0.05..15.0 — flag byte 0x00 + * - `true` = max bolus **volume** (U), range 0.05..25.0 — flag byte 0x01 + * + * The flag byte is inverted: `flag = if (isMaxVolume) 0x01 else 0x00`. + * + * Request wire format (4 bytes): `[0] 0x17, [1] flag, [2..3] value = [intPart, centiPart]`. + * Response (0x77): `[0] 0x77, [1] type, [2] resultCode`. + */ +class InfusionThresholdCommand( + private val isMaxVolume: Boolean, + private val value: Double +) : BleCommand { + + init { + val range = if (isMaxVolume) MAX_VOLUME_RANGE else MAX_SPEED_RANGE + require(value in range) { "value $value out of range $range (isMaxVolume=$isMaxVolume)" } + } + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = + byteArrayOf(requestOpcode, if (isMaxVolume) FLAG_MAX_VOLUME else FLAG_MAX_SPEED) + encodeUnitCenti(value) + + override fun decode(responsePayload: ByteArray): InfusionThresholdResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return InfusionThresholdResponse(type = responsePayload.u(1), resultCode = responsePayload.u(2)) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x17 + const val RESPONSE_OPCODE: Byte = 0x77 + private const val FLAG_MAX_SPEED: Byte = 0x00 + private const val FLAG_MAX_VOLUME: Byte = 0x01 + private val MAX_SPEED_RANGE = 0.05..15.0 + private val MAX_VOLUME_RANGE = 0.05..25.0 + private const val MIN_RESPONSE_LENGTH = 3 + } +} + +/** Decoded response from [InfusionThresholdCommand]: echoed [type] flag + pump [resultCode] (0 = SUCCESS). */ +data class InfusionThresholdResponse( + val type: Int, + val resultCode: Int +) : BleResponse diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/MacAddressCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/MacAddressCommand.kt new file mode 100644 index 000000000000..2cb9185e9c38 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/MacAddressCommand.kt @@ -0,0 +1,72 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand +import app.aaps.pump.carelevo.ble.BleResponse + +/** + * `CMD_MAC_ADDR_REQ` (0x3B) → `CMD_MAC_ADDR_RES` (0x9B). + * + * Reads the peripheral's MAC address during initial bonding. Read-only, no pump state change. + * + * Request wire format (2 bytes): + * ``` + * [0] 0x3B opcode + * [1] key caller-chosen random key, echoed in the response checksum calc + * ``` + * + * Response wire format (≥ 7 bytes): + * ``` + * [0] 0x9B opcode + * [1..6] macAddress 6 bytes, most-significant byte first + * [7..] checkSum remainder of the payload, derived from (address || key) + * ``` + */ +class MacAddressCommand( + /** Random byte chosen by the caller; the response's checksum is derived from this + the address. */ + private val key: Byte +) : BleCommand { + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = byteArrayOf(requestOpcode, key) + + override fun decode(responsePayload: ByteArray): MacAddressResponse { + require(responsePayload.isNotEmpty() && responsePayload[0] == expectedResponseOpcode) { + "expected opcode 0x${"%02X".format(expectedResponseOpcode)}, got " + + (responsePayload.getOrNull(0)?.let { "0x${"%02X".format(it)}" } ?: "empty") + } + require(responsePayload.size >= MIN_LENGTH) { + "response too short: ${responsePayload.size} bytes, need at least $MIN_LENGTH" + } + + val macHex = responsePayload.copyOfRange(ADDRESS_START, ADDRESS_END).toUppercaseHex() + val checkSumHex = responsePayload.copyOfRange(CHECKSUM_START, responsePayload.size).toUppercaseHex() + return MacAddressResponse(macAddress = macHex, checkSum = checkSumHex) + } + + private fun ByteArray.toUppercaseHex(): String = + joinToString(separator = "") { "%02X".format(it) } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x3B + const val RESPONSE_OPCODE: Byte = 0x9B.toByte() + + private const val ADDRESS_START = 1 + private const val ADDRESS_END = 7 // exclusive — bytes 1..6 inclusive + private const val CHECKSUM_START = 7 + private const val MIN_LENGTH = CHECKSUM_START + 1 // opcode + 6 address bytes + ≥1 checksum byte + } +} + +/** + * Decoded response from [MacAddressCommand]. + * + * Both fields are upper-case hex strings with no separators (e.g. `"94B2161D2F6D"`). + * The consumer is responsible for any display formatting. + */ +data class MacAddressResponse( + val macAddress: String, + val checkSum: String +) : BleResponse diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/NeedleAckCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/NeedleAckCommand.kt new file mode 100644 index 000000000000..e7657c0ded21 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/NeedleAckCommand.kt @@ -0,0 +1,38 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand + +/** + * `CMD_NEEDLE_INSERT_ACK` (0x19) → `CMD_CANNULA_INSERTION_RPT_ACK_RES` (0x7A). Confirms the cannula + * insertion result back to the patch. + * + * Request wire format (2 bytes): `[0] 0x19, [1] flag`, where `isSuccess=true → 0x00`, + * `isSuccess=false → 0x01`. + * + * **Behavioral note:** this command awaits the 0x7A response. Confirm on hardware that the pump actually + * sends 0x7A before relying on the awaited result. Response (0x7A): `[0] 0x7A, [1] resultCode` → + * [SimpleResultResponse]. + */ +class NeedleAckCommand( + private val isSuccess: Boolean +) : BleCommand { + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = byteArrayOf(requestOpcode, if (isSuccess) FLAG_SUCCESS else FLAG_FAILURE) + + override fun decode(responsePayload: ByteArray): SimpleResultResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return SimpleResultResponse(responsePayload.u(1)) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x19 + const val RESPONSE_OPCODE: Byte = 0x7A + private const val FLAG_SUCCESS: Byte = 0x00 + private const val FLAG_FAILURE: Byte = 0x01 + private const val MIN_RESPONSE_LENGTH = 2 + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/NeedleStatusCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/NeedleStatusCommand.kt new file mode 100644 index 000000000000..a99edf83e007 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/NeedleStatusCommand.kt @@ -0,0 +1,34 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand + +/** + * `CMD_NEEDLE_STATUS_REQ` (0x1A) → `CMD_NEEDLE_INSERT_RPT` (0x79). Requests the cannula-insertion status. + * + * **Asymmetric opcodes** (0x1A → 0x79, not the usual `+0x60`) and a **long physical wait**: the pump + * replies only once the needle insertion has finished, with no fixed timeout on the wire. The caller + * must therefore wrap `request(...)` in a generous `withTimeout(...)`. + * + * Request wire format (2 bytes): `[0] 0x1A, [1] 0x00`. + * Response (0x79): `[0] 0x79, [1] resultCode` → [SimpleResultResponse]. + */ +class NeedleStatusCommand : BleCommand { + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = byteArrayOf(requestOpcode, REQUEST_FLAG) + + override fun decode(responsePayload: ByteArray): SimpleResultResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return SimpleResultResponse(responsePayload.u(1)) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x1A + const val RESPONSE_OPCODE: Byte = 0x79 + private const val REQUEST_FLAG: Byte = 0x00 + private const val MIN_RESPONSE_LENGTH = 2 + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/NoticeThresholdCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/NoticeThresholdCommand.kt new file mode 100644 index 000000000000..ffb88d21e3af --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/NoticeThresholdCommand.kt @@ -0,0 +1,58 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand +import app.aaps.pump.carelevo.ble.BleResponse + +/** + * `CMD_NOTICE_THRESHOLD_REQ` (0x15) → `CMD_NOTICE_THRESHOLD_RES` (0x75). Sets a reminder threshold — + * one command class for both consumers, parameterized by [thresholdType]: + * - [TYPE_LOW_INSULIN] (0): low-insulin reminder amount (value range 20..50 U) + * - [TYPE_EXPIRY] (1): patch-expiry reminder hours (value range 24..167) + * + * Request wire format (3 bytes): `[0] 0x15, [1] thresholdType, [2] value`. + * + * Response (0x75): `[0] 0x75, [1] thresholdType` — no result code is carried on the wire, so arrival of + * the frame is the success signal; [resultCode] is always reported as 0. + */ +class NoticeThresholdCommand( + private val thresholdType: Int, + private val value: Int +) : BleCommand { + + init { + require(thresholdType == TYPE_LOW_INSULIN || thresholdType == TYPE_EXPIRY) { + "thresholdType must be $TYPE_LOW_INSULIN or $TYPE_EXPIRY, got $thresholdType" + } + val range = if (thresholdType == TYPE_EXPIRY) EXPIRY_RANGE else LOW_INSULIN_RANGE + require(value in range) { "value $value out of range $range for type $thresholdType" } + } + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = byteArrayOf(requestOpcode, thresholdType.toByte(), value.toByte()) + + override fun decode(responsePayload: ByteArray): NoticeThresholdResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return NoticeThresholdResponse(thresholdType = responsePayload.u(1), resultCode = FABRICATED_RESULT) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x15 + const val RESPONSE_OPCODE: Byte = 0x75 + const val TYPE_LOW_INSULIN = 0 + const val TYPE_EXPIRY = 1 + + private val LOW_INSULIN_RANGE = 20..50 + private val EXPIRY_RANGE = 24..167 + private const val MIN_RESPONSE_LENGTH = 2 + private const val FABRICATED_RESULT = 0 // no result code on the wire; frame arrival = success + } +} + +/** Decoded response from [NoticeThresholdCommand]: the echoed [thresholdType]; [resultCode] is always 0 (see command). */ +data class NoticeThresholdResponse( + val thresholdType: Int, + val resultCode: Int +) : BleResponse diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/PatchDiscardCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/PatchDiscardCommand.kt new file mode 100644 index 000000000000..306387aa9856 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/PatchDiscardCommand.kt @@ -0,0 +1,31 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand + +/** + * `CMD_PATCH_DISCARD_REQ` (0x36) → `CMD_PATCH_DISCARD_RES` (0x96). Discards (deactivates) the patch. + * Medium-criticality write with no arguments — sends only the opcode byte. + * + * Request wire format (1 byte): `[0] 0x36`. + * Response (0x96): `[0] 0x96, [1] resultCode` → [SimpleResultResponse] (0 = SUCCESS). Any timestamp/cmd + * bookkeeping is not on the wire; this decoder keeps only the result byte. + */ +class PatchDiscardCommand : BleCommand { + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = byteArrayOf(requestOpcode) + + override fun decode(responsePayload: ByteArray): SimpleResultResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return SimpleResultResponse(responsePayload.u(1)) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x36 + const val RESPONSE_OPCODE: Byte = 0x96.toByte() + private const val MIN_RESPONSE_LENGTH = 2 + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/PatchInfoCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/PatchInfoCommand.kt new file mode 100644 index 000000000000..d7e66756b705 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/PatchInfoCommand.kt @@ -0,0 +1,98 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleMultiCommand +import app.aaps.pump.carelevo.ble.BleResponse + +/** + * `CMD_PATCH_INFO_REQ` (0x33) → the patch answers with TWO report frames: RPT1 (0x93, serial) + + * RPT2 (0x94, firmware/model). A [BleMultiCommand]: one write, both notifications collected (any + * order) before [decode]. + * + * Request wire format (1 byte): `[0] 0x33` — no args, no framing. + * + * RPT1 (0x93) wire format (≥ 15 bytes): + * ``` + * [0] 0x93 opcode + * [1] resultCode + * [2..14] serialNumber 13 chars (each byte → ASCII char) + * ``` + * RPT2 (0x94) wire format (real frame is 16 bytes, indices 0..15): + * ``` + * [0] 0x94 opcode + * [1] resultCode + * [2..5] firmwareVersion 4 chars (each byte → ASCII char) + * [11..15] modelName decimal-int string of each present byte (byte 10 is skipped; byte 16 is + * out of range in the 16-byte frame) + * ``` + * **Two protocol quirks:** `modelName` uses each byte's decimal value (not ASCII) and skips byte 10. + * `bootDateTime` is NOT on the wire at all — the persistence layer stamps it from the phone clock — so + * that mapping stays at the persistence layer, not here; this command decodes only wire bytes. + */ +class PatchInfoCommand : BleMultiCommand { + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcodes: Set = setOf(RPT1_OPCODE, RPT2_OPCODE) + + override fun encode(): ByteArray = byteArrayOf(requestOpcode) + + override fun decode(responses: Map): PatchInfoResponse { + val rpt1 = requireNotNull(responses[RPT1_OPCODE]) { "missing RPT1 (0x93)" } + val rpt2 = requireNotNull(responses[RPT2_OPCODE]) { "missing RPT2 (0x94)" } + require(rpt1.size >= RPT1_MIN_LENGTH) { "RPT1 too short: ${rpt1.size} < $RPT1_MIN_LENGTH" } + require(rpt2.size >= RPT2_MIN_LENGTH) { "RPT2 too short: ${rpt2.size} < $RPT2_MIN_LENGTH" } + + // modelName is best-effort over bytes 11..16: the real 0x94 frame is 16 bytes (indices 0..15) + // so byte 16 is absent — clamp to the frame end (a 16-byte frame → model bytes 11..15). + val modelName = + if (MODEL_START <= rpt2.lastIndex) rpt2.decimalString(MODEL_START, minOf(MODEL_END, rpt2.lastIndex)) + else "" + + return PatchInfoResponse( + serialResultCode = rpt1[1].toUByte().toInt(), + serialNumber = rpt1.asciiString(SERIAL_START, SERIAL_END), + detailResultCode = rpt2[1].toUByte().toInt(), + firmwareVersion = rpt2.asciiString(FIRMWARE_START, FIRMWARE_END), + modelName = modelName + ) + } + + /** Bytes [startInclusive]..[endInclusive] as ASCII, each byte → one char. */ + private fun ByteArray.asciiString(startInclusive: Int, endInclusive: Int): String = + (startInclusive..endInclusive).joinToString("") { this[it].toUByte().toInt().toChar().toString() } + + /** Bytes [startInclusive]..[endInclusive] as the concatenation of each byte's DECIMAL value (protocol quirk). */ + private fun ByteArray.decimalString(startInclusive: Int, endInclusive: Int): String = + (startInclusive..endInclusive).joinToString("") { this[it].toUByte().toInt().toString() } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x33 + const val RPT1_OPCODE: Byte = 0x93.toByte() + const val RPT2_OPCODE: Byte = 0x94.toByte() + + private const val SERIAL_START = 2 + private const val SERIAL_END = 14 // bytes 2..14 = 13 chars + private const val FIRMWARE_START = 2 + private const val FIRMWARE_END = 5 // bytes 2..5 = 4 chars + private const val MODEL_START = 11 + private const val MODEL_END = 16 // bytes 11..16 (byte 10 skipped) + + private const val RPT1_MIN_LENGTH = SERIAL_END + 1 // 15 — full serial required + private const val RPT2_MIN_LENGTH = FIRMWARE_END + 1 // 6 — firmware required; modelName is best-effort (see decode) + } +} + +/** + * Decoded response from [PatchInfoCommand] — the wire content of RPT1 (0x93) + RPT2 (0x94). + * + * [serialResultCode]/[detailResultCode] are the raw pump result bytes (0 = SUCCESS). `bootDateTime` is + * intentionally absent — it is not carried on the wire; the persistence layer stamps it from the phone + * clock. + */ +data class PatchInfoResponse( + val serialResultCode: Int, + val serialNumber: String, + val detailResultCode: Int, + val firmwareVersion: String, + val modelName: String +) : BleResponse diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/PumpResumeCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/PumpResumeCommand.kt new file mode 100644 index 000000000000..c1757c1f8963 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/PumpResumeCommand.kt @@ -0,0 +1,72 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand +import app.aaps.pump.carelevo.ble.BleResponse + +/** + * `CMD_PUMP_RESTART_REQ` (0x27) → `CMD_PUMP_RESTART_RES` (0x87). Resumes (restarts) a paused pump — + * **SAFETY-CRITICAL** delivery-affecting operation. + * + * Request wire format (3 bytes): `[0] 0x27, [1] mode, [2] subId`. + * - `mode` — range-checked 1..4, emitted as one raw byte (`mode.toByte()`). + * - `subId` — only 0, 1 or 4 are valid and each maps to itself (4→0x04, 1→0x01, else→0x00); any other + * value is rejected. + * + * Response (0x87): `[0] 0x87, [1] resultCode, [2] mode, [3] subId`. **`subId` is OPTIONAL** — `data[3]` + * is read only when the response is longer than 3 bytes, otherwise `subId` defaults to 0 (variable-length + * reply). Any timestamp/cmd bookkeeping is not on the wire and is dropped by this pure wire decoder. (The + * separate 0x88 `BasalRestartRpt` is async and is **not** this response.) + */ +class PumpResumeCommand( + private val mode: Int, + private val subId: Int +) : BleCommand { + + init { + require(mode in MODE_RANGE) { "mode $mode out of range $MODE_RANGE" } + require(subId in VALID_SUB_IDS) { "subId $subId invalid, must be one of $VALID_SUB_IDS" } + } + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = byteArrayOf(requestOpcode, mode.toByte(), encodeSubId(subId)) + + override fun decode(responsePayload: ByteArray): PumpResumeResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return PumpResumeResponse( + resultCode = responsePayload.u(1), + mode = responsePayload.u(2), + // subId is optional: only present when the reply carries a 4th byte, else 0. + subId = if (responsePayload.size > SUB_ID_INDEX) responsePayload.u(SUB_ID_INDEX) else 0 + ) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x27 + const val RESPONSE_OPCODE: Byte = 0x87.toByte() + + private val MODE_RANGE = 1..4 + private val VALID_SUB_IDS = setOf(0, 1, 4) + private const val SUB_ID_INDEX = 3 + private const val MIN_RESPONSE_LENGTH = 3 + + /** Encodes subId: 4→0x04, 1→0x01, else→0x00 (identity for valid ids). */ + private fun encodeSubId(subId: Int): Byte = when (subId) { + 4 -> 0x04 + 1 -> 0x01 + else -> 0x00 + } + } +} + +/** + * Decoded response from [PumpResumeCommand]: pump [resultCode] (0 = SUCCESS), echoed [mode], and echoed + * [subId] (0 when the reply omits the optional 4th byte). + */ +data class PumpResumeResponse( + val resultCode: Int, + val mode: Int, + val subId: Int +) : BleResponse diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/PumpStopCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/PumpStopCommand.kt new file mode 100644 index 000000000000..5ed23e0d90b8 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/PumpStopCommand.kt @@ -0,0 +1,61 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand + +/** + * `CMD_PUMP_STOP_REQ` (0x26) → `CMD_PUMP_STOP_RES` (0x86). **SAFETY-CRITICAL** — suspends (stops) insulin + * delivery for [durationMinutes], answered with a result-only acknowledgement. + * + * The later `CMD_PUMP_STOP_RPT` (0x8A) the pump pushes when the stop period ends is an async/unsolicited + * report — **not** this command's response; it is handled via [app.aaps.pump.carelevo.ble.BleClient.unsolicitedEvents]. + * + * Request wire format (4 bytes), splitting the duration into hours+minutes: + * ``` + * [0] 0x26 opcode + * [1] durationMinutes / 60 hours — range 0..5 + * [2] durationMinutes % 60 minutes — range 0..60 + * [3] subId source — range 0..1 + * ``` + * Each field is one raw byte (`value.toByte()`), range-checked as above. Because `durationMinutes % 60` + * is always 0..59 for a non-negative duration, the effective span is `0..359` minutes (5 h 59 min). + * + * Response (0x86): `[0] 0x86, [1] resultCode` → [SimpleResultResponse]. Any timestamp/cmd bookkeeping is + * not on the wire, so this pure wire decoder drops it (the caller owns timestamping). + */ +class PumpStopCommand( + private val durationMinutes: Int, + private val subId: Int +) : BleCommand { + + private val hours = durationMinutes / MINUTES_PER_HOUR + private val minutes = durationMinutes % MINUTES_PER_HOUR + + init { + require(hours in HOURS_RANGE) { "durationMinutes $durationMinutes → hours $hours out of range $HOURS_RANGE" } + require(minutes in MINUTES_RANGE) { "durationMinutes $durationMinutes → minutes $minutes out of range $MINUTES_RANGE" } + require(subId in SUB_ID_RANGE) { "subId $subId out of range $SUB_ID_RANGE" } + } + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = + byteArrayOf(requestOpcode, hours.toByte(), minutes.toByte(), subId.toByte()) + + override fun decode(responsePayload: ByteArray): SimpleResultResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return SimpleResultResponse(responsePayload.u(1)) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x26 + const val RESPONSE_OPCODE: Byte = 0x86.toByte() + + private const val MINUTES_PER_HOUR = 60 + private val HOURS_RANGE = 0..5 + private val MINUTES_RANGE = 0..60 + private val SUB_ID_RANGE = 0..1 + private const val MIN_RESPONSE_LENGTH = 2 + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/SafetyCheckCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/SafetyCheckCommand.kt new file mode 100644 index 000000000000..895fcb99ec7a --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/SafetyCheckCommand.kt @@ -0,0 +1,74 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleResponse +import app.aaps.pump.carelevo.ble.BleStreamCommand + +/** + * `CMD_SAFETY_CHECK_REQ` (0x12) → `CMD_SAFETY_CHECK_RES` (0x72). The activation safety check: the pump + * primes/checks and streams progress reports (`REP_REQUEST`/`REP_REQUEST1`) then a terminal SUCCESS/error. + * This is the CareLevo [BleStreamCommand] — collected until [isTerminal]. + * + * Request wire format (1 byte): `[0] 0x12`. + * + * Response wire format (0x72, ≥ 4 bytes): + * ``` + * [0] 0x72 opcode + * [1] resultCode (SafetyCheckResult: 0 SUCCESS, 4/18 progress, 1/2/3/11/12 errors) + * [2..3] insulinVolume = [2]*100 + [3] (U; ×100 hundreds-byte quirk, no decimal byte) + * [4..5] durationSeconds = [4]*60 + [5] — only if size > 4; else the 210 s fallback + * ``` + * [isTerminal] is `true` for any non-progress result (SUCCESS **or** an error code), so the stream + * completes with the terminal frame rather than timing out; the consumer maps [SafetyCheckResponse.resultCode] + * to SUCCESS vs error. + */ +class SafetyCheckCommand : BleStreamCommand { + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = byteArrayOf(requestOpcode) + + override fun decode(responsePayload: ByteArray): SafetyCheckResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + // Duration bytes are read only when size > 4; a short/progress frame falls back to 210 s. + // Real frames are size 4 or ≥ 6. + val durationSeconds = + if (responsePayload.size > 4) responsePayload.u(4) * SECONDS_PER_MINUTE + responsePayload.u(5) + else DEFAULT_DURATION_SECONDS + return SafetyCheckResponse( + resultCode = responsePayload.u(1), + insulinVolume = responsePayload.u(2) * HUNDRED + responsePayload.u(3), + durationSeconds = durationSeconds + ) + } + + override fun isTerminal(response: SafetyCheckResponse): Boolean = + response.resultCode != REP_REQUEST && response.resultCode != REP_REQUEST1 + + companion object { + + const val REQUEST_OPCODE: Byte = 0x12 + const val RESPONSE_OPCODE: Byte = 0x72 + + // SafetyCheckResult codes (see CarelevoBtEnums). + const val RESULT_SUCCESS = 0 + const val REP_REQUEST = 4 // progress + const val REP_REQUEST1 = 18 // progress + + private const val MIN_RESPONSE_LENGTH = 4 + private const val DEFAULT_DURATION_SECONDS = 210 + private const val SECONDS_PER_MINUTE = 60 + private const val HUNDRED = 100 + } +} + +/** + * A single streamed safety-check frame: [resultCode] (progress `REP_REQUEST`/`REP_REQUEST1`, terminal + * `RESULT_SUCCESS`, or an error code), remaining [insulinVolume] (U), and the [durationSeconds] the pump + * expects the check to take (drives the UI countdown). + */ +data class SafetyCheckResponse( + val resultCode: Int, + val insulinVolume: Int, + val durationSeconds: Int +) : BleResponse diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/SetTimeCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/SetTimeCommand.kt new file mode 100644 index 000000000000..d2ef8a601888 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/SetTimeCommand.kt @@ -0,0 +1,87 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand +import app.aaps.pump.carelevo.ble.BleMultiCommand +import org.joda.time.DateTime + +/** + * Shared `CMD_SET_TIME_REQ` (0x11) request encoding: + * `[0] 0x11, [1] subId, [2..7] dateTime = [yy,MM,dd,HH,mm,ss], [8..9] volume = [vol/100, vol%100], [10] aidMode]`. + * + * [dateTime] is an explicit parameter (caller passes `DateTime.now()`) so `encode()` is deterministic and + * unit-testable. Year = last two digits (`year % 100`); volume validated 0..300. + */ +internal fun encodeSetTime(subId: Int, volume: Int, aidMode: Int, dateTime: DateTime): ByteArray { + require(volume in VOLUME_RANGE) { "volume out of range $VOLUME_RANGE" } + // last two digits via arithmetic — can't throw on unusual years. + val yy = dateTime.year % 100 + return byteArrayOf( + SET_TIME_REQUEST_OPCODE, + subId.toByte(), + yy.toByte(), + dateTime.monthOfYear.toByte(), + dateTime.dayOfMonth.toByte(), + dateTime.hourOfDay.toByte(), + dateTime.minuteOfHour.toByte(), + dateTime.secondOfMinute.toByte(), + (volume / HUNDRED).toByte(), + (volume % HUNDRED).toByte(), + aidMode.toByte() + ) +} + +internal const val SET_TIME_REQUEST_OPCODE: Byte = 0x11 +private val VOLUME_RANGE = 0..300 +private const val HUNDRED = 100 + +/** + * `CMD_SET_TIME_REQ` (0x11) → `CMD_SET_TIME_RES` (0x71). Sets the patch clock — the normal path (e.g. the + * timezone/DST update, subId=1). Response is result-only → [SimpleResultResponse]. + * + * NOTE the dual shape of 0x11: during **activation** the same write is used with subId=0 to make the patch + * emit its info reports (0x93+0x94) and 0x71 is ignored — that path is [SetTimeForPatchInfoCommand]. + */ +class SetTimeCommand( + private val subId: Int, + private val volume: Int, + private val aidMode: Int, + private val dateTime: DateTime +) : BleCommand { + + override val requestOpcode: Byte = SET_TIME_REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = encodeSetTime(subId, volume, aidMode, dateTime) + + override fun decode(responsePayload: ByteArray): SimpleResultResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return SimpleResultResponse(responsePayload.u(1)) + } + + companion object { + + const val RESPONSE_OPCODE: Byte = 0x71 + private const val MIN_RESPONSE_LENGTH = 2 + } +} + +/** + * `CMD_SET_TIME_REQ` (0x11) → RPT1 (0x93) + RPT2 (0x94). The **activation** variant of set-time (subId=0): + * the same 0x11 write triggers the patch to emit its two info reports, which this collects as a + * [BleMultiCommand] and decodes into a [PatchInfoResponse] (reusing [PatchInfoCommand]'s wire decode). + * The 0x71 set-time ack is ignored on this path — see [SetTimeCommand] for the normal shape. + */ +class SetTimeForPatchInfoCommand( + private val subId: Int, + private val volume: Int, + private val aidMode: Int, + private val dateTime: DateTime +) : BleMultiCommand { + + override val requestOpcode: Byte = SET_TIME_REQUEST_OPCODE + override val expectedResponseOpcodes: Set = setOf(PatchInfoCommand.RPT1_OPCODE, PatchInfoCommand.RPT2_OPCODE) + + override fun encode(): ByteArray = encodeSetTime(subId, volume, aidMode, dateTime) + + override fun decode(responses: Map): PatchInfoResponse = PatchInfoCommand().decode(responses) +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/TempBasalCancelCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/TempBasalCancelCommand.kt new file mode 100644 index 000000000000..c5f0bbc20eea --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/TempBasalCancelCommand.kt @@ -0,0 +1,32 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand + +/** + * `CMD_TEMP_BASAL_CANCEL_REQ` (0x2D) → `CMD_TEMP_BASAL_CANCEL_RES` (0x8D). Cancels a running temp-basal + * program. Criticality: high (a delivery-control command). + * + * Request wire format (1 byte): `[0] 0x2D` — no arguments, so the opcode is the whole frame. + * + * Response (0x8D): `[0] 0x8D, [1] resultCode` → [SimpleResultResponse] (`resultCode` 0 = SUCCESS). The + * wire reply is result-only. + */ +class TempBasalCancelCommand : BleCommand { + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = byteArrayOf(requestOpcode) + + override fun decode(responsePayload: ByteArray): SimpleResultResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return SimpleResultResponse(responsePayload.u(1)) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x2D + const val RESPONSE_OPCODE: Byte = 0x8D.toByte() + private const val MIN_RESPONSE_LENGTH = 2 + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/TempBasalCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/TempBasalCommand.kt new file mode 100644 index 000000000000..bdab8d3501a5 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/TempBasalCommand.kt @@ -0,0 +1,89 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand + +/** + * `CMD_TEMP_BASAL_REQ` (0x23) → `CMD_TEMP_BASAL_RES` (0x83). Starts a temp basal — **safety-critical + * delivery**. One command class for both modes, parameterized by [Mode]: + * - [Mode.BY_UNIT] — an absolute rate in U/h. + * - [Mode.BY_PERCENT] — a percentage of the scheduled basal. + * + * **The ByUnit↔ByPercent ASYMMETRY must be exact** (a wrong byte here is a patient-safety bug): + * ``` + * BY_UNIT (6 bytes): [0] 0x23, [1..2] unit = [intPart, centiPart], [3] hour, [4] min, [5] 0x00 + * BY_PERCENT (5 bytes): [0] 0x23, [1..2] value = [intPart, centiPart], [3] hour, [4] min + * ``` + * Wire-format quirks: + * - **ByUnit trailing 0x00:** BY_UNIT appends a sixth `0x00` byte; BY_PERCENT appends **nothing** + * (the asymmetry). + * - **ByPercent `/100.0`:** [byPercent] encodes `infusionPercent / 100.0` (e.g. 150 % → `1.50` → + * `[1, 50]`) before [encodeUnitCenti]. + * - **ByUnit has NO value-range check:** any `infusionUnit` is accepted on the wire. ByPercent + * validates the divided value, so `percent/100.0` must be in `0.0..200.0`. + * - hour is range-checked 0..24, minute 0..59 — same ranges for both modes. + * + * Response (0x83): `[0] 0x83, [1] resultCode` → [SimpleResultResponse] (0 = SUCCESS). The wire reply is + * result-only. + */ +class TempBasalCommand private constructor( + private val mode: Mode, + private val value: Double, + private val hour: Int, + private val minute: Int +) : BleCommand { + + /** Which of the two 0x23 variants this instance encodes. Use [byUnit] / [byPercent] to build. */ + enum class Mode { BY_UNIT, BY_PERCENT } + + init { + require(hour in HOUR_RANGE) { "hour $hour out of range $HOUR_RANGE" } + require(minute in MINUTE_RANGE) { "minute $minute out of range $MINUTE_RANGE" } + // BY_UNIT has no value-range check on the wire. + if (mode == Mode.BY_PERCENT) { + require(value in PERCENT_VALUE_RANGE) { "percent/100.0 = $value out of range $PERCENT_VALUE_RANGE" } + } + } + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray { + val head = byteArrayOf(requestOpcode) + encodeUnitCenti(value) + byteArrayOf(hour.toByte(), minute.toByte()) + return when (mode) { + Mode.BY_UNIT -> head + byteArrayOf(TRAILING_BOOL_TRUE) // BY_UNIT trailing byte = 0x00 + Mode.BY_PERCENT -> head // no trailing byte (the asymmetry) + } + } + + override fun decode(responsePayload: ByteArray): SimpleResultResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return SimpleResultResponse(responsePayload.u(1)) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x23 + const val RESPONSE_OPCODE: Byte = 0x83.toByte() + + private const val TRAILING_BOOL_TRUE: Byte = 0x00 // BY_UNIT trailing byte + private val HOUR_RANGE = 0..24 + private val MINUTE_RANGE = 0..59 + private val PERCENT_VALUE_RANGE = 0.0..200.0 + private const val MIN_RESPONSE_LENGTH = 2 + + /** + * ByUnit variant — absolute temp basal rate [infusionUnit] (U/h) for [infusionHour]:[infusionMin]. + * No range check on [infusionUnit]. Encodes the 6-byte frame with the trailing 0x00. + */ + fun byUnit(infusionUnit: Double, infusionHour: Int, infusionMin: Int): TempBasalCommand = + TempBasalCommand(Mode.BY_UNIT, infusionUnit, infusionHour, infusionMin) + + /** + * ByPercent variant — [infusionPercent] percent of scheduled basal for [infusionHour]:[infusionMin]. + * Encodes `infusionPercent / 100.0` (so 100 → `1.00`); that divided value must be in `0.0..200.0`. + * Encodes the 5-byte frame (no trailing byte). + */ + fun byPercent(infusionPercent: Int, infusionHour: Int, infusionMin: Int): TempBasalCommand = + TempBasalCommand(Mode.BY_PERCENT, infusionPercent.toDouble() / 100.0, infusionHour, infusionMin) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/ThresholdSetupCommand.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/ThresholdSetupCommand.kt new file mode 100644 index 000000000000..d15e6e9bdaec --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/commands/ThresholdSetupCommand.kt @@ -0,0 +1,65 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleCommand + +/** + * `CMD_THRESHOLD_SETUP_REQ` (0x1B) → `CMD_THRESHOLD_SETUP_RES` (0x7B). Activation bundle that sets all + * patch thresholds at once. + * + * Request wire format (7 bytes): `[0] 0x1B, [1] insulinRemainsThreshold, [2] expiryThreshold, + * [3..4] maxBasalSpeed = [int,centi], [5..6] maxBolusDose = [int,centi], [7] buzzFlag`. + * + * **Wire-format notes:** + * - `insulinRemainsThreshold` is a single wire byte, so `INS_REMAIN_RANGE` is 10..255; a value > 255 + * would wrap on encode (300 → 0x2C = 44), silently misprogramming the low-insulin alarm threshold, + * so it is rejected instead. Every real caller feeds the low-insulin reminder preference (entries + * 20..50 U), so nothing legitimately exceeds one byte; an out-of-range caller fails loudly rather + * than alarming 6× too late. + * - `buzzFlag` is the inverted buzz flag → `buzzUse=true → 0x01`, `buzzUse=false → 0x00` (same double + * inversion as [BuzzModeCommand]). + * + * Response (0x7B): `[0] 0x7B, [1] resultCode` → [SimpleResultResponse]. + */ +class ThresholdSetupCommand( + private val insulinRemainsThreshold: Int, + private val expiryThreshold: Int, + private val maxBasalSpeed: Double, + private val maxBolusDose: Double, + private val buzzUse: Boolean +) : BleCommand { + + init { + require(insulinRemainsThreshold in INS_REMAIN_RANGE) { "insulinRemainsThreshold out of range $INS_REMAIN_RANGE" } + require(expiryThreshold in EXPIRY_RANGE) { "expiryThreshold out of range $EXPIRY_RANGE" } + require(maxBasalSpeed in MAX_BASAL_SPEED_RANGE) { "maxBasalSpeed out of range $MAX_BASAL_SPEED_RANGE" } + require(maxBolusDose in MAX_BOLUS_DOSE_RANGE) { "maxBolusDose out of range $MAX_BOLUS_DOSE_RANGE" } + } + + override val requestOpcode: Byte = REQUEST_OPCODE + override val expectedResponseOpcode: Byte = RESPONSE_OPCODE + + override fun encode(): ByteArray = + byteArrayOf(requestOpcode, insulinRemainsThreshold.toByte(), expiryThreshold.toByte()) + + encodeUnitCenti(maxBasalSpeed) + + encodeUnitCenti(maxBolusDose) + + byteArrayOf(if (buzzUse) BUZZ_ON else BUZZ_OFF) + + override fun decode(responsePayload: ByteArray): SimpleResultResponse { + requireResponseFrame(responsePayload, RESPONSE_OPCODE, MIN_RESPONSE_LENGTH) + return SimpleResultResponse(responsePayload.u(1)) + } + + companion object { + + const val REQUEST_OPCODE: Byte = 0x1B + const val RESPONSE_OPCODE: Byte = 0x7B + // One wire byte; 10..255 valid, larger values would wrap — see class KDoc. + private val INS_REMAIN_RANGE = 10..255 + private val EXPIRY_RANGE = 24..167 + private val MAX_BASAL_SPEED_RANGE = 0.05..15.0 + private val MAX_BOLUS_DOSE_RANGE = 0.05..25.0 + private const val BUZZ_ON: Byte = 0x01 // buzzUse=true → 0x01 (inverted) + private const val BUZZ_OFF: Byte = 0x00 // buzzUse=false → 0x00 (inverted) + private const val MIN_RESPONSE_LENGTH = 2 + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/data/CarelevoBleEnums.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/data/CarelevoBleEnums.kt new file mode 100644 index 000000000000..2e3263adb8f5 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/data/CarelevoBleEnums.kt @@ -0,0 +1,99 @@ +package app.aaps.pump.carelevo.ble.data + +enum class BondingState { + BOND_NONE, + BOND_BONDING, + BOND_BONDED, + BOND_ERROR; + + companion object { + + fun Int.codeToBondingResult() = when (this) { + -1 -> BOND_NONE + 10 -> BOND_NONE + 11 -> BOND_BONDING + 12 -> BOND_BONDED + else -> throw IllegalArgumentException("Invalid code") + } + } +} + +enum class PeripheralConnectionState { + CONN_STATE_NONE, + CONN_STATE_CONNECTING, + CONN_STATE_CONNECTED, + CONN_STATE_DISCONNECTING, + CONN_STATE_DISCONNECTED; + + companion object { + + fun Int.codeToConnectionResult() = when (this) { + -1 -> CONN_STATE_NONE + 0 -> CONN_STATE_DISCONNECTED + 1 -> CONN_STATE_CONNECTING + 2 -> CONN_STATE_CONNECTED + 3 -> CONN_STATE_DISCONNECTING + else -> throw IllegalArgumentException("Invalid code") + } + } +} + +enum class ServiceDiscoverState { + DISCOVER_STATE_NONE, + DISCOVER_STATE_DISCOVERED, + DISCOVER_STATE_FAILED, + DISCOVER_STATE_CLEARED; + + companion object { + + fun Int.codeToDiscoverResult() = when (this) { + -1 -> DISCOVER_STATE_CLEARED + 0 -> DISCOVER_STATE_DISCOVERED + else -> DISCOVER_STATE_FAILED + } + } +} + +enum class DeviceModuleState { + DEVICE_NONE, + DEVICE_STATE_OFF, + DEVICE_STATE_TUNING_OFF, + DEVICE_STATE_ON, + DEVICE_STATE_TURNING_ON; + + companion object { + + fun Int.codeToDeviceResult() = when (this) { + -1 -> DEVICE_NONE + 10 -> DEVICE_STATE_OFF + 11 -> DEVICE_STATE_TURNING_ON + 12 -> DEVICE_STATE_ON + 13 -> DEVICE_STATE_TUNING_OFF + else -> throw IllegalArgumentException("Invalid code") + } + } +} + +enum class NotificationState { + NOTIFICATION_NONE, + NOTIFICATION_ENABLED, + NOTIFICATION_DISABLED; + + companion object { + + fun Int.codeToNotificationResult() = when (this) { + -1 -> NOTIFICATION_NONE + 0 -> NOTIFICATION_DISABLED + 1 -> NOTIFICATION_ENABLED + else -> throw IllegalArgumentException("Invalid code") + } + } +} + +enum class FailureState { + FAILURE_INVALID_PARAMS, + FAILURE_RESOURCE_NOT_INITIALIZED, + FAILURE_PERMISSION_NOT_GRANTED, + FAILURE_BT_NOT_ENABLED, + FAILURE_COMMAND_NOT_EXECUTABLE +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/data/CarelevoBleModels.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/data/CarelevoBleModels.kt new file mode 100644 index 000000000000..4e6139fc3403 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/data/CarelevoBleModels.kt @@ -0,0 +1,103 @@ +package app.aaps.pump.carelevo.ble.data + +import android.bluetooth.BluetoothDevice + +data class ScannedDevice( + var device: BluetoothDevice, + var rssi: Int +) + +data class BleState( + val isEnabled: DeviceModuleState, + val isBonded: BondingState, + val isServiceDiscovered: ServiceDiscoverState, + val isConnected: PeripheralConnectionState, + val isNotificationEnabled: NotificationState +) + +fun BleState.isAvailable(): Boolean { + return isEnabled != DeviceModuleState.DEVICE_STATE_OFF +} + +fun BleState.isPeripheralConnected(): Boolean { + return isConnected == PeripheralConnectionState.CONN_STATE_CONNECTED +} + +fun BleState.isConnected(): Boolean { + return isEnabled == DeviceModuleState.DEVICE_STATE_ON && + isBonded == BondingState.BOND_BONDED && + isConnected == PeripheralConnectionState.CONN_STATE_CONNECTED && + isServiceDiscovered == ServiceDiscoverState.DISCOVER_STATE_DISCOVERED && + isNotificationEnabled == NotificationState.NOTIFICATION_ENABLED +} + +fun BleState.shouldBeConnected(): Boolean { + return isEnabled == DeviceModuleState.DEVICE_STATE_ON && + isBonded == BondingState.BOND_BONDED && + isConnected == PeripheralConnectionState.CONN_STATE_CONNECTED && + isServiceDiscovered != ServiceDiscoverState.DISCOVER_STATE_DISCOVERED && + isNotificationEnabled != NotificationState.NOTIFICATION_ENABLED +} + +fun BleState.shouldBeDiscovered(): Boolean { + return isEnabled == DeviceModuleState.DEVICE_STATE_ON && + isBonded == BondingState.BOND_BONDED && + isServiceDiscovered == ServiceDiscoverState.DISCOVER_STATE_DISCOVERED && + isNotificationEnabled != NotificationState.NOTIFICATION_ENABLED +} + +fun BleState.shouldBeNotificationEnabled(): Boolean { + return isEnabled == DeviceModuleState.DEVICE_STATE_ON && + isBonded == BondingState.BOND_BONDED && + isConnected == PeripheralConnectionState.CONN_STATE_CONNECTED && + isServiceDiscovered == ServiceDiscoverState.DISCOVER_STATE_DISCOVERED && + isNotificationEnabled == NotificationState.NOTIFICATION_ENABLED +} + +fun BleState.isDiscoverCleared(): Boolean { + return isEnabled == DeviceModuleState.DEVICE_STATE_ON && + isBonded == BondingState.BOND_BONDED && + isConnected == PeripheralConnectionState.CONN_STATE_DISCONNECTED && + isServiceDiscovered == ServiceDiscoverState.DISCOVER_STATE_CLEARED && + isNotificationEnabled == NotificationState.NOTIFICATION_DISABLED +} + +fun BleState.isReInitialized(): Boolean { + return isEnabled == DeviceModuleState.DEVICE_STATE_ON && + isBonded == BondingState.BOND_NONE && + isConnected == PeripheralConnectionState.CONN_STATE_DISCONNECTED && + isServiceDiscovered == ServiceDiscoverState.DISCOVER_STATE_DISCOVERED && + isNotificationEnabled == NotificationState.NOTIFICATION_DISABLED +} + +fun BleState.isAbnormalFailed(): Boolean { + return isEnabled == DeviceModuleState.DEVICE_STATE_ON && + isBonded == BondingState.BOND_NONE && + isConnected == PeripheralConnectionState.CONN_STATE_DISCONNECTED && + isServiceDiscovered == ServiceDiscoverState.DISCOVER_STATE_DISCOVERED && + isNotificationEnabled == NotificationState.NOTIFICATION_DISABLED +} + +fun BleState.isAbnormalBondingFailed(): Boolean { + return isEnabled == DeviceModuleState.DEVICE_STATE_ON && + isBonded == BondingState.BOND_NONE && + isConnected == PeripheralConnectionState.CONN_STATE_CONNECTED && + isServiceDiscovered == ServiceDiscoverState.DISCOVER_STATE_DISCOVERED && + isNotificationEnabled == NotificationState.NOTIFICATION_DISABLED +} + +fun BleState.isFailed(): Boolean { + return isEnabled == DeviceModuleState.DEVICE_STATE_ON && + isBonded == BondingState.BOND_NONE && + isConnected == PeripheralConnectionState.CONN_STATE_CONNECTED && + isServiceDiscovered == ServiceDiscoverState.DISCOVER_STATE_NONE && + isNotificationEnabled == NotificationState.NOTIFICATION_DISABLED +} + +fun BleState.isPairingFailed(): Boolean { + return isEnabled == DeviceModuleState.DEVICE_STATE_ON && + isBonded == BondingState.BOND_NONE && + isConnected == PeripheralConnectionState.CONN_STATE_DISCONNECTED && + isServiceDiscovered == ServiceDiscoverState.DISCOVER_STATE_NONE && + isNotificationEnabled == NotificationState.NOTIFICATION_NONE +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/data/CarelevoBleParams.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/data/CarelevoBleParams.kt new file mode 100644 index 000000000000..2ec6dca10cfa --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/data/CarelevoBleParams.kt @@ -0,0 +1,14 @@ +package app.aaps.pump.carelevo.ble.data + +import java.util.UUID + +data class BleParams( + val cccd: UUID, + val serviceUuid: UUID, + val txUuid: UUID, + val rxUUID: UUID +) + +data class ConfigParams( + val isForeground: Boolean = true +) \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/data/CarelevoBleResults.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/data/CarelevoBleResults.kt new file mode 100644 index 000000000000..159272911bb9 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/data/CarelevoBleResults.kt @@ -0,0 +1,57 @@ +package app.aaps.pump.carelevo.ble.data + +import java.util.UUID + +data class CharacterResult( + val uuidCharacteristic: UUID? = null, + val value: ByteArray? = null, + val codeStatus: Int? = null +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CharacterResult + + if (uuidCharacteristic != other.uuidCharacteristic) return false + if (value != null) { + if (other.value == null) return false + if (!value.contentEquals(other.value)) return false + } else if (other.value != null) return false + if (codeStatus != other.codeStatus) return false + + return true + } + + override fun hashCode(): Int { + var result = uuidCharacteristic?.hashCode() ?: 0 + result = 31 * result + (value?.contentHashCode() ?: 0) + result = 31 * result + (codeStatus ?: 0) + return result + } +} + +sealed class PeripheralScanResult( + open val value: List +) { + + data class Init( + override val value: List + ) : PeripheralScanResult(value) + + data class Success( + override val value: List + ) : PeripheralScanResult(value) + + data class Failed( + override val value: List + ) : PeripheralScanResult(value) +} + +sealed class CommandResult { + data class Pending(val data: T) : CommandResult() + data class Success(val data: T) : CommandResult() + data class Failure(val state: FailureState, val message: String) : CommandResult() + data class Error(val e: Throwable) : CommandResult() +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/gatt/BleTransportGattConnection.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/gatt/BleTransportGattConnection.kt new file mode 100644 index 000000000000..39e7d7bd6677 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/gatt/BleTransportGattConnection.kt @@ -0,0 +1,197 @@ +package app.aaps.pump.carelevo.ble.gatt + +import app.aaps.core.interfaces.pump.ble.BleTransport +import app.aaps.core.interfaces.pump.ble.BleTransportListener +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.UUID + +/** + * Adapts the shared fleet [BleTransport] to carelevo's [GattConnection] contract, so the + * [app.aaps.pump.carelevo.ble.BleClientImpl] correlation layer runs on the same BLE transport + * abstraction Dana/Equil/Medtrum use. + * + * It registers itself as the transport's single [BleTransportListener] and translates each callback + * into either: + * - a per-op [CompletableDeferred] that unblocks a suspend [GattConnection] method, or + * - a [GattEvent] on the hot [events] flow (for [BleClient]'s notification routing and + * connection-state observers). + * + * Internally: per-op [CompletableDeferred]s for the suspend ops plus an events-[SharedFlow] fed from + * the [BleTransportListener] callbacks. + * + * **Semantic note (write acks):** [BleTransportListener.onCharacteristicWritten] carries + * no status, so a write the BLE stack silently drops is NOT fast-failed with [GattWriteException] — + * it degrades to the caller's `withTimeout(...)`, which every [BleClient.request] already applies. + * The *not-connected* case is still fast-failed (the transport calls `onConnectionStateChanged(false)` + * synchronously from the write). This matches the fleet transport behaviour. + * + * **Single-characteristic transport:** the shared `BleGatt` has one hardcoded write and one notify + * characteristic and no write-type parameter, so [writeCharacteristic]'s `withResponse` flag is not + * honored (writes are always acknowledged) and the `uuid` args are validated against the + * constructor UUIDs rather than used for routing — a mismatched UUID fails loudly instead of silently + * targeting the wrong characteristic. + * + * Threading: [gattMutex] serializes suspend ops; `CompletableDeferred.complete(...)` is thread-safe + * and called directly from the (binder thread) listener callbacks. Events are likewise emitted + * SYNCHRONOUSLY from the callbacks via [emitEvent] (`tryEmit` into the 64-slot buffer) — an async + * `scope.launch { emit(...) }` detour would open a lost-event window at session teardown: a + * response arriving just as the per-op timeout cancels [scope] would silently never reach + * [BleClient]'s router, and a pump that DID execute a dose command would be reported as failed. + * [scope] is only a fallback for the (pathological) buffer-full case. + * + * One adapter instance owns the transport's single listener slot for one connection lifecycle; + * [close] releases it. + * + * @param writeUuid stamped onto emitted [GattEvent.WriteAck] (the transport has one write char). + * @param notifyUuid stamped onto emitted [GattEvent.Notification] (the transport has one notify char). + */ +class BleTransportGattConnection( + private val transport: BleTransport, + private val writeUuid: UUID, + private val notifyUuid: UUID, + private val scope: CoroutineScope +) : GattConnection, BleTransportListener { + + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + override val events: SharedFlow = _events.asSharedFlow() + + private val gattMutex = Mutex() + + @Volatile + private var writeAck: CompletableDeferred? = null + + @Volatile + private var discoveryAck: CompletableDeferred? = null + + @Volatile + private var descriptorAck: CompletableDeferred? = null + + @Volatile + private var closed = false + + init { + transport.setListener(this) + } + + /** + * Opens the underlying GATT connection to [address]. Not part of [GattConnection] (which models + * an already-open connection) — callers await [GattEvent.ConnectionStateChanged]`(CONNECTED)` on + * [events] before calling [discoverServices]. Returns `false` if the BLE stack refused to start + * the connection (e.g. missing permission or unknown device). + */ + fun connect(address: String): Boolean = transport.gatt.connect(address) + + // ===== GattConnection ===== + + override suspend fun writeCharacteristic( + uuid: UUID, + payload: ByteArray, + withResponse: Boolean + ) = gattMutex.withLock { + // Self-guard: after close() the transport listener is null, so the not-connected callback + // below can no longer abort the deferred — fail fast instead of hanging until the caller's + // withTimeout. + if (closed) throw GattWriteException("connection closed") + require(uuid == writeUuid) { "unexpected write characteristic $uuid; transport has a single write char $writeUuid" } + val deferred = CompletableDeferred() + writeAck = deferred + try { + // Fire-and-forget on the shared transport; if not connected it synchronously calls + // onConnectionStateChanged(false), which aborts this deferred → fast-fail preserved. + transport.gatt.writeCharacteristic(payload) + val acked = deferred.await() + if (!acked) throw GattWriteException("onCharacteristicWritten reported failure") + } finally { + writeAck = null + } + } + + override suspend fun discoverServices() = gattMutex.withLock { + if (closed) throw GattDiscoveryException("connection closed") + val deferred = CompletableDeferred() + discoveryAck = deferred + try { + transport.gatt.discoverServices() + if (!deferred.await()) throw GattDiscoveryException("onServicesDiscovered reported failure") + } finally { + discoveryAck = null + } + } + + override suspend fun enableNotifications(uuid: UUID) = gattMutex.withLock { + if (closed) throw GattDiscoveryException("connection closed") + require(uuid == notifyUuid) { "unexpected notify characteristic $uuid; transport has a single notify char $notifyUuid" } + val deferred = CompletableDeferred() + descriptorAck = deferred + try { + transport.gatt.enableNotifications() + if (!deferred.await()) throw GattDiscoveryException("onDescriptorWritten reported failure") + } finally { + descriptorAck = null + } + } + + override fun close() { + // Idempotent (GattConnection contract). The flag also makes subsequent suspend ops fail fast + // rather than hang, since setListener(null) below disables the not-connected abort callback. + closed = true + // Abort any in-flight suspending operations first — the transport's close() is not + // guaranteed to produce a final onConnectionStateChanged on every chipset. + writeAck?.completeExceptionally(GattWriteException("connection closed")) + discoveryAck?.completeExceptionally(GattDiscoveryException("connection closed")) + descriptorAck?.completeExceptionally(GattDiscoveryException("connection closed")) + transport.gatt.close() + transport.setListener(null) + } + + // ===== BleTransportListener ===== + + /** + * Delivers [event] to [events] synchronously on the calling (binder) thread. Falls back to an + * async emit only if the buffer is full — see the class-level threading note for why the + * synchronous path is load-bearing. + */ + private fun emitEvent(event: GattEvent) { + if (!_events.tryEmit(event)) scope.launch { _events.emit(event) } + } + + override fun onConnectionStateChanged(connected: Boolean) { + val state = if (connected) GattConnState.CONNECTED else GattConnState.DISCONNECTED + emitEvent(GattEvent.ConnectionStateChanged(state)) + if (!connected) { + // Connection gone — abort any in-flight operations (BleClient aborts its own request + // waiter separately off the DISCONNECTED event). + writeAck?.completeExceptionally(GattWriteException("disconnected")) + discoveryAck?.completeExceptionally(GattDiscoveryException("disconnected")) + descriptorAck?.completeExceptionally(GattDiscoveryException("disconnected")) + } + } + + override fun onServicesDiscovered(success: Boolean) { + discoveryAck?.complete(success) + emitEvent(GattEvent.ServicesDiscovered(success)) + } + + override fun onDescriptorWritten() { + // Transport only invokes this on GATT_SUCCESS; a failed CCCD write produces no callback and + // is surfaced by the caller's withTimeout (mirrors the fleet transport behaviour). + descriptorAck?.complete(true) + } + + override fun onCharacteristicChanged(data: ByteArray) { + emitEvent(GattEvent.Notification(notifyUuid, data.copyOf())) + } + + override fun onCharacteristicWritten() { + // No status on the shared listener — a delivered write-ack is treated as success. + writeAck?.complete(true) + emitEvent(GattEvent.WriteAck(writeUuid, true)) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/gatt/GattConnection.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/gatt/GattConnection.kt new file mode 100644 index 000000000000..1b04a2137484 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ble/gatt/GattConnection.kt @@ -0,0 +1,112 @@ +package app.aaps.pump.carelevo.ble.gatt + +import kotlinx.coroutines.flow.SharedFlow +import java.util.UUID + +/** + * Thin abstraction over the Android `BluetoothGatt` lifecycle for a single peripheral. + * + * The concrete production implementation wraps `BluetoothGatt` + `BluetoothGattCallback` + * and bridges the callback into a hot [SharedFlow] of [GattEvent]s. Tests substitute + * `FakeGattConnection` to script BLE behaviour without Android types. + * + * Designed as the single chokepoint through which all BLE I/O for the CareLevo driver + * flows. Callers above this layer never reference `BluetoothGatt` directly. + */ +interface GattConnection { + + /** + * Hot, multicast stream of GATT events as the BLE stack reports them. + * + * Subscribers receive only events emitted after they subscribe (no replay). + * Use [BleClient]-style suspend operations for request/response correlation — + * direct subscription to this flow is intended for connection-state observers + * and unsolicited notifications (alarms, status pushes). + */ + val events: SharedFlow + + /** + * Writes [payload] to the characteristic identified by [uuid]. + * + * Suspends until the BLE stack delivers a matching [GattEvent.WriteAck], then returns + * normally on success or throws [GattWriteException] on failure. Does not impose + * its own timeout — wrap the call in `withTimeout(...)` if a deadline is needed. + * + * @param withResponse `true` selects acknowledged write (`WRITE_TYPE_DEFAULT`), + * `false` selects fire-and-forget (`WRITE_TYPE_NO_RESPONSE`). + */ + suspend fun writeCharacteristic( + uuid: UUID, + payload: ByteArray, + withResponse: Boolean = true + ) + + /** + * Initiates service discovery and suspends until the BLE stack reports completion. + * Throws [GattDiscoveryException] if the stack reports failure. + */ + suspend fun discoverServices() + + /** + * Subscribes to peripheral-initiated notifications/indications on [uuid]. + * Subsequent [GattEvent.Notification] events for [uuid] will appear in [events]. + */ + suspend fun enableNotifications(uuid: UUID) + + /** + * Releases the underlying `BluetoothGatt` and aborts any in-flight suspending + * operations with their typed exceptions (`GattWriteException` or + * `GattDiscoveryException`). The [events] flow remains active for any existing + * subscribers — callers relying on flow completion should instead cancel the + * scope that owns the subscription. Idempotent — safe to call repeatedly. + */ + fun close() +} + +/** + * Anything the GATT layer reports asynchronously. Sealed so consumers must handle + * every case exhaustively. + */ +sealed interface GattEvent { + + /** A peripheral-initiated notification or indication. */ + data class Notification( + val uuid: UUID, + val payload: ByteArray + ) : GattEvent + + /** Connection state transitioned. */ + data class ConnectionStateChanged( + val state: GattConnState + ) : GattEvent + + /** + * Service discovery finished. [ok] is `true` only when the stack reported + * `GATT_SUCCESS`; consumers should also re-check the discovered service set + * matches what the protocol expects before proceeding. + */ + data class ServicesDiscovered( + val ok: Boolean + ) : GattEvent + + /** Acknowledgement of a previously requested write. */ + data class WriteAck( + val uuid: UUID, + val ok: Boolean + ) : GattEvent +} + +/** + * Coarse connection state derived from `BluetoothProfile.STATE_*` and the gatt's + * disconnect/close lifecycle. + */ +enum class GattConnState { + + CONNECTING, + CONNECTED, + DISCONNECTING, + DISCONNECTED +} + +class GattWriteException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) +class GattDiscoveryException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/command/CarelevoActivationCommands.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/command/CarelevoActivationCommands.kt new file mode 100644 index 000000000000..fb9059db9fa0 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/command/CarelevoActivationCommands.kt @@ -0,0 +1,76 @@ +package app.aaps.pump.carelevo.command + +import app.aaps.core.interfaces.queue.CustomCommand +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType + +/** + * Activation operations routed through the AAPS CommandQueue (managed connect-before-execute / + * reconnect). See [CarelevoActivationExecutor]. Each is a marker command; the executor runs the + * corresponding use case blocking on the queue thread and returns the result. + */ + +class CmdNeedleCheck : CustomCommand { + + override val statusDescription: String = "NEEDLE CHECK" +} + +class CmdSetBasal : CustomCommand { + + override val statusDescription: String = "SET BASAL" +} + +class CmdAdditionalPriming : CustomCommand { + + override val statusDescription: String = "ADDITIONAL PRIMING" +} + +class CmdDiscard : CustomCommand { + + override val statusDescription: String = "DISCARD PATCH" +} + +class CmdPumpStop(val durationMin: Int) : CustomCommand { + + override val statusDescription: String = "PUMP STOP" +} + +class CmdPumpResume : CustomCommand { + + override val statusDescription: String = "PUMP RESUME" +} + +class CmdTimeZoneUpdate(val insulinAmount: Int) : CustomCommand { + + override val statusDescription: String = "TIMEZONE UPDATE" +} + +class CmdUpdateMaxBolus(val maxBolusDose: Double) : CustomCommand { + + override val statusDescription: String = "UPDATE MAX BOLUS" +} + +class CmdUpdateLowInsulinNotice(val hours: Int) : CustomCommand { + + override val statusDescription: String = "UPDATE LOW INSULIN NOTICE" +} + +class CmdUpdateExpiredThreshold(val hours: Int) : CustomCommand { + + override val statusDescription: String = "UPDATE EXPIRY THRESHOLD" +} + +class CmdUpdateBuzzer(val on: Boolean) : CustomCommand { + + override val statusDescription: String = "UPDATE BUZZER" +} + +class CmdAlarmClear(val alarmId: String, val alarmType: AlarmType, val alarmCause: AlarmCause) : CustomCommand { + + override val statusDescription: String = "ALARM CLEAR" +} + +class CmdAlarmClearPatchDiscard(val alarmId: String, val alarmType: AlarmType, val alarmCause: AlarmCause) : CustomCommand { + + override val statusDescription: String = "ALARM CLEAR PATCH DISCARD" +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/command/CarelevoActivationExecutor.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/command/CarelevoActivationExecutor.kt new file mode 100644 index 000000000000..eb271a37b160 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/command/CarelevoActivationExecutor.kt @@ -0,0 +1,441 @@ +package app.aaps.pump.carelevo.command + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.queue.CustomCommand +import app.aaps.pump.carelevo.ble.BleCommand +import app.aaps.pump.carelevo.ble.CarelevoBleSession +import app.aaps.pump.carelevo.ble.commands.AdditionalPrimingCommand +import app.aaps.pump.carelevo.ble.commands.AlarmClearCommand +import app.aaps.pump.carelevo.ble.commands.BuzzModeCommand +import app.aaps.pump.carelevo.ble.commands.InfusionThresholdCommand +import app.aaps.pump.carelevo.ble.commands.NeedleStatusCommand +import app.aaps.pump.carelevo.ble.commands.NoticeThresholdCommand +import app.aaps.pump.carelevo.ble.commands.PatchDiscardCommand +import app.aaps.pump.carelevo.ble.commands.PumpResumeCommand +import app.aaps.pump.carelevo.ble.commands.PumpStopCommand +import app.aaps.pump.carelevo.ble.commands.SafetyCheckCommand +import app.aaps.pump.carelevo.ble.commands.SetTimeCommand +import app.aaps.pump.carelevo.ble.commands.SimpleResultResponse +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.domain.model.bt.SafetyCheckResult +import app.aaps.pump.carelevo.domain.model.bt.SafetyCheckResultModel +import app.aaps.pump.carelevo.domain.type.SafetyProgress +import app.aaps.pump.carelevo.domain.usecase.alarm.AlarmClearPatchDiscardUseCase +import app.aaps.pump.carelevo.domain.usecase.alarm.AlarmClearRequestUseCase +import app.aaps.pump.carelevo.domain.usecase.basal.CarelevoSetBasalProgramUseCase +import app.aaps.pump.carelevo.domain.usecase.infusion.CarelevoPumpResumeUseCase +import app.aaps.pump.carelevo.domain.usecase.infusion.CarelevoPumpStopUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchNeedleInsertionCheckUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchSafetyCheckUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoUpdateLowInsulinNoticeAmountUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoUpdateMaxBolusDoseUseCase +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.runBlocking +import org.joda.time.DateTime +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton +import kotlin.jvm.optionals.getOrNull + +/** + * Runs activation operations that are routed through the AAPS [app.aaps.core.interfaces.queue.CommandQueue] + * as custom commands. The plugin's `executeCustomCommand` delegates here; it is invoked on the queue + * worker thread. Each op runs BLOCKING over the coroutine [CarelevoBleSession] (its own per-op + * connection session — connect → exchange → close) and returns a [PumpEnactResult]. The use cases are + * persistence-only seams (`persistX`/`buildX`); all BLE goes through [CarelevoBleSession]. + * + * The safety check streams progress (`Progress` → `Success`/`Error`); since a custom command returns a + * single result, that progress is republished on [safetyProgress] so the wizard can drive its countdown. + */ +@Singleton +class CarelevoActivationExecutor @Inject constructor( + private val aapsLogger: AAPSLogger, + private val pumpEnactResultProvider: Provider, + private val carelevoPatch: CarelevoPatch, + private val safetyCheckUseCase: CarelevoPatchSafetyCheckUseCase, + private val needleCheckUseCase: CarelevoPatchNeedleInsertionCheckUseCase, + private val setBasalUseCase: CarelevoSetBasalProgramUseCase, + private val pumpStopUseCase: CarelevoPumpStopUseCase, + private val pumpResumeUseCase: CarelevoPumpResumeUseCase, + private val updateMaxBolusDoseUseCase: CarelevoUpdateMaxBolusDoseUseCase, + private val updateLowInsulinNoticeAmountUseCase: CarelevoUpdateLowInsulinNoticeAmountUseCase, + private val alarmClearRequestUseCase: AlarmClearRequestUseCase, + private val alarmClearPatchDiscardUseCase: AlarmClearPatchDiscardUseCase, + private val bleSession: CarelevoBleSession +) { + + private companion object { + + private const val RESULT_SUCCESS = 0 // pump result byte 0 = SUCCESS / BY_REQ + private const val STOP_RESUME_SUB_ID = 0 // stop/resume use subId/causeId = 0 + private const val RESUME_MODE = 1 // resume mode = 1 + private const val TIMEZONE_SUB_ID = 1 // set-time subId = 1 for the timezone/DST update path + private const val TIMEZONE_AID_MODE = 0 // set-time aidMode = 0 + private const val NEEDLE_CHECK_TIMEOUT_MS = 90_000L // physical cannula insertion may take a while + private const val SAFETY_PROGRESS_HEADROOM_SEC = 30 // Progress timeout = firstFrame.durationSeconds + 30 + } + + private val _safetyProgress = MutableSharedFlow(extraBufferCapacity = 16) + val safetyProgress: SharedFlow = _safetyProgress.asSharedFlow() + + fun execute(command: CustomCommand): PumpEnactResult? = when (command) { + is CmdSafetyCheck -> runSafetyCheck() + is CmdNeedleCheck -> runNeedleCheck() + is CmdSetBasal -> runSetBasal() + is CmdAdditionalPriming -> runSingleWrite("additionalPriming") { AdditionalPrimingCommand() } + is CmdDiscard -> runDiscard() + is CmdPumpStop -> runPumpStop(command.durationMin) + is CmdPumpResume -> runPumpResume() + is CmdTimeZoneUpdate -> runSingleWrite("timeZoneUpdate") { + SetTimeCommand(subId = TIMEZONE_SUB_ID, volume = command.insulinAmount, aidMode = TIMEZONE_AID_MODE, dateTime = DateTime.now()) + } + + is CmdUpdateMaxBolus -> runUpdateMaxBolus(command.maxBolusDose) + is CmdUpdateLowInsulinNotice -> runUpdateLowInsulinNotice(command.hours) + is CmdUpdateExpiredThreshold -> runUpdateExpiredThreshold(command.hours) + is CmdUpdateBuzzer -> runUpdateBuzzer(command.on) + is CmdAlarmClear -> runAlarmClear(command) + is CmdAlarmClearPatchDiscard -> runAlarmClearPatchDiscard(command) + else -> null + } + + /** + * Initial basal program (**delivery-critical**): build the 3-program plan with the use case's exact + * mapping, send all three [app.aaps.pump.carelevo.ble.commands.BasalProgramCommand]s over ONE session + * (via [CarelevoBleSession.runBasalProgram]), and only on all-success reuse the use case's `mode=1` + + * infusion-info persist. Activation-only op. + */ + private fun runSetBasal(): PumpEnactResult { + val result = pumpEnactResultProvider.get() + val profile = carelevoPatch.profile.value?.getOrNull() + ?: return result.success(false).enacted(false).comment("profile not set") + val address = carelevoPatch.getPatchInfoAddress() + ?: return result.success(false).enacted(false).comment("no patch address") + return try { + val plan = setBasalUseCase.buildBasalProgramPlan(profile) + val programmed = runBlocking { + bleSession.runBasalProgram(address, plan.programs) + } + val persisted = if (programmed) setBasalUseCase.persistBasalProgram(plan.segments) else false + aapsLogger.info(LTag.PUMPCOMM, "newBle.setBasal programmed=$programmed persisted=$persisted") + val ok = programmed && persisted + result.success(ok).enacted(ok) + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.setBasal FAILED", e) + result.success(false).enacted(false).comment(e.message ?: "error") + } + } + + /** + * Pump stop (suspend delivery): write [PumpStopCommand] on the session; on success + * (`resultCode == 0`) persist the suspended state (`pumpStopUseCase.persistStopped`). + */ + private fun runPumpStop(durationMin: Int): PumpEnactResult { + val result = pumpEnactResultProvider.get() + val address = carelevoPatch.getPatchInfoAddress() + ?: return result.success(false).enacted(false).comment("no patch address") + return try { + val response = runBlocking { + bleSession.runSingle(address, PumpStopCommand(durationMinutes = durationMin, subId = STOP_RESUME_SUB_ID)) + } + if (response.resultCode != RESULT_SUCCESS) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.pumpStop rejected result=${response.resultCode}") + return result.success(false).enacted(false).comment("stop result ${response.resultCode}") + } + val persisted = pumpStopUseCase.persistStopped(durationMin) + aapsLogger.info(LTag.PUMPCOMM, "newBle.pumpStop OK durationMin=$durationMin persisted=$persisted") + result.success(persisted).enacted(persisted) + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.pumpStop FAILED", e) + result.success(false).enacted(false).comment(e.message ?: "error") + } + } + + private fun runPumpResume(): PumpEnactResult { + val result = pumpEnactResultProvider.get() + val address = carelevoPatch.getPatchInfoAddress() + ?: return result.success(false).enacted(false).comment("no patch address") + return try { + val response = runBlocking { + bleSession.runSingle(address, PumpResumeCommand(mode = RESUME_MODE, subId = STOP_RESUME_SUB_ID)) + } + if (response.resultCode != RESULT_SUCCESS) { // 0 = BY_REQ + aapsLogger.error(LTag.PUMPCOMM, "newBle.pumpResume rejected result=${response.resultCode}") + return result.success(false).enacted(false).comment("resume result ${response.resultCode}") + } + val persisted = pumpResumeUseCase.persistResumed() + aapsLogger.info(LTag.PUMPCOMM, "newBle.pumpResume OK persisted=$persisted") + result.success(persisted).enacted(persisted) + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.pumpResume FAILED", e) + result.success(false).enacted(false).comment(e.message ?: "error") + } + } + + /** + * Buzzer setting via [BuzzModeCommand] (0x18 → 0x78). Pure write, no persistence (the preference is + * the source of truth). + */ + private fun runUpdateBuzzer(on: Boolean): PumpEnactResult { + val result = pumpEnactResultProvider.get() + val address = carelevoPatch.getPatchInfoAddress() + ?: return result.success(false).enacted(false).comment("no patch address") + return try { + val response = runBlocking { + bleSession.runSingle(address, BuzzModeCommand(use = on)) + } + val success = response.resultCode == RESULT_SUCCESS + aapsLogger.info(LTag.PUMPCOMM, "newBle.buzzer OK on=$on result=${response.resultCode}") + result.success(success).enacted(success) + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.buzzer FAILED", e) + result.success(false).enacted(false).comment(e.message ?: "error") + } + } + + /** + * Max-bolus setting. Never push a threshold mid-bolus (persist locally + defer via + * `needMaxBolusDoseSyncPatch`); otherwise write [InfusionThresholdCommand] + * (max-volume), then persist with `synced` = the patch confirmed. On any failure the value is still + * persisted with the sync flag set so the deferred-sync re-pushes on reconnect. + */ + private fun runUpdateMaxBolus(value: Double): PumpEnactResult { + val result = pumpEnactResultProvider.get() + if (updateMaxBolusDoseUseCase.isBolusRunning()) { + val persisted = updateMaxBolusDoseUseCase.persistMaxBolusDose(value, synced = false) + aapsLogger.info(LTag.PUMPCOMM, "newBle.maxBolus bolus-running → deferred persisted=$persisted") + return result.success(persisted).enacted(persisted) + } + val address = carelevoPatch.getPatchInfoAddress() + ?: return result.success(false).enacted(false).comment("no patch address") + return try { + val response = runBlocking { + bleSession.runSingle(address, InfusionThresholdCommand(isMaxVolume = true, value = value)) + } + val pushed = response.resultCode == RESULT_SUCCESS + val persisted = updateMaxBolusDoseUseCase.persistMaxBolusDose(value, synced = pushed) + aapsLogger.info(LTag.PUMPCOMM, "newBle.maxBolus OK value=$value result=${response.resultCode} persisted=$persisted") + val success = pushed && persisted + result.success(success).enacted(success) + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.maxBolus FAILED", e) + updateMaxBolusDoseUseCase.persistMaxBolusDose(value, synced = false) // keep desired value + defer re-sync + result.success(false).enacted(false).comment(e.message ?: "error") + } + } + + /** + * Low-insulin-notice setting. The 0x75 response fabricates a result of 0, so arrival = success. + * Persists via the use case with `synced` = arrived; on failure the value is + * persisted deferred for the next reconnect. + */ + private fun runUpdateLowInsulinNotice(hours: Int): PumpEnactResult { + val result = pumpEnactResultProvider.get() + val address = carelevoPatch.getPatchInfoAddress() + ?: return result.success(false).enacted(false).comment("no patch address") + return try { + val response = runBlocking { + bleSession.runSingle(address, NoticeThresholdCommand(thresholdType = NoticeThresholdCommand.TYPE_LOW_INSULIN, value = hours)) + } + val pushed = response.resultCode == RESULT_SUCCESS + val persisted = updateLowInsulinNoticeAmountUseCase.persistLowInsulinNoticeAmount(hours, synced = pushed) + aapsLogger.info(LTag.PUMPCOMM, "newBle.lowInsulinNotice OK hours=$hours result=${response.resultCode} persisted=$persisted") + val success = pushed && persisted + result.success(success).enacted(success) + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.lowInsulinNotice FAILED", e) + updateLowInsulinNoticeAmountUseCase.persistLowInsulinNoticeAmount(hours, synced = false) + result.success(false).enacted(false).comment(e.message ?: "error") + } + } + + /** + * Patch-expiry-reminder setting. No userSettingInfo persist (the preference is the source of truth), + * so this is a pure BLE write like the buzzer; arrival of the 0x75 frame (fabricated result 0) = success. + */ + private fun runUpdateExpiredThreshold(hours: Int): PumpEnactResult { + val result = pumpEnactResultProvider.get() + val address = carelevoPatch.getPatchInfoAddress() + ?: return result.success(false).enacted(false).comment("no patch address") + return try { + val response = runBlocking { + bleSession.runSingle(address, NoticeThresholdCommand(thresholdType = NoticeThresholdCommand.TYPE_EXPIRY, value = hours)) + } + val success = response.resultCode == RESULT_SUCCESS + aapsLogger.info(LTag.PUMPCOMM, "newBle.expiryThreshold OK hours=$hours result=${response.resultCode}") + result.success(success).enacted(success) + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.expiryThreshold FAILED", e) + result.success(false).enacted(false).comment(e.message ?: "error") + } + } + + /** + * Shared path for a pure single-response WRITE with no persistence (buzzer-style ops: + * additional-priming, timezone, …): run [command] on the session, success = `resultCode == 0`. + * Runs on the queue worker (blocked inside the command). + */ + private fun runSingleWrite(label: String, command: () -> BleCommand): PumpEnactResult { + val result = pumpEnactResultProvider.get() + val address = carelevoPatch.getPatchInfoAddress() + ?: return result.success(false).enacted(false).comment("no patch address") + return try { + val response = runBlocking { + bleSession.runSingle(address, command()) + } + val success = response.resultCode == RESULT_SUCCESS + aapsLogger.info(LTag.PUMPCOMM, "ble.$label OK result=${response.resultCode}") + result.success(success).enacted(success) + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "ble.$label FAILED", e) + result.success(false).enacted(false).comment(e.message ?: "error") + } + } + + /** + * BLE discard (tell the patch to stop delivering) + teardown. Routed through the queue so the + * teardown runs HERE on the queue worker thread (no reconnect race, ~300ms BLE teardown off the Main + * thread). The STOP write alone ([PatchDiscardCommand] 0x36) decides success; a teardown failure must + * not flip an already-stopped patch to failure. The DB-only force-discard stays in the ViewModels as + * the fallback for when the patch cannot be reached at all. + */ + private fun runDiscard(): PumpEnactResult { + val result = pumpEnactResultProvider.get() + val address = carelevoPatch.getPatchInfoAddress() + ?: return result.success(false).enacted(false).comment("no patch address") + val stopped = try { + val response = runBlocking { + bleSession.runSingle(address, PatchDiscardCommand()) + } + response.resultCode == RESULT_SUCCESS + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.discard stop exception", e) + false + } + if (stopped) carelevoPatch.discardTeardown() + aapsLogger.info(LTag.PUMPCOMM, "newBle.discard stopped=$stopped") + return result.success(stopped).enacted(stopped) + } + + /** + * Cannula-insertion (needle) check. Long timeout — the pump reports 0x79 only after the physical + * insertion. `resultCode == 0` = SUCCESS; the `checkNeedle`/failure-count persist is reused from the + * use case. Activation-only op. + */ + private fun runNeedleCheck(): PumpEnactResult { + val result = pumpEnactResultProvider.get() + val address = carelevoPatch.getPatchInfoAddress() + ?: return result.success(false).enacted(false).comment("no patch address") + return try { + val response = runBlocking { + bleSession.runSingle(address, NeedleStatusCommand(), timeoutMs = NEEDLE_CHECK_TIMEOUT_MS) + } + val inserted = response.resultCode == RESULT_SUCCESS + val persisted = needleCheckUseCase.persistNeedleResult(inserted) + aapsLogger.info(LTag.PUMPCOMM, "newBle.needleCheck inserted=$inserted result=${response.resultCode} persisted=$persisted") + val ok = inserted && persisted + result.success(ok).enacted(ok) + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.needleCheck FAILED", e) + result.success(false).enacted(false).comment(e.message ?: "error") + } + } + + /** + * Safety Check over `requestStream`. Streams each 0x72 frame; progress frames + * (REP_REQUEST/REP_REQUEST1) drive the wizard countdown via [_safetyProgress] (one Progress emit), + * the terminal SUCCESS frame persists `checkSafety` + emits Success, any other + * terminal is an Error. Activation-only op. + */ + private fun runSafetyCheck(): PumpEnactResult { + val result = pumpEnactResultProvider.get() + val address = carelevoPatch.getPatchInfoAddress() + ?: return result.success(false).enacted(false).comment("no patch address") + var success = false + var progressEmitted = false + return try { + runBlocking { + bleSession.runSafetyCheck(address) { frame -> + when (frame.resultCode) { + SafetyCheckCommand.REP_REQUEST, SafetyCheckCommand.REP_REQUEST1 -> { + if (!progressEmitted) { + progressEmitted = true + _safetyProgress.tryEmit(SafetyProgress.Progress((frame.durationSeconds + SAFETY_PROGRESS_HEADROOM_SEC).toLong())) + } + } + + SafetyCheckCommand.RESULT_SUCCESS -> { + if (safetyCheckUseCase.persistSafetyChecked()) { + success = true + _safetyProgress.tryEmit(SafetyProgress.Success(SafetyCheckResultModel(SafetyCheckResult.SUCCESS, frame.insulinVolume, frame.durationSeconds))) + } else { + _safetyProgress.tryEmit(SafetyProgress.Error(IllegalStateException("update patch info is failed"))) + } + } + + else -> + _safetyProgress.tryEmit(SafetyProgress.Error(IllegalStateException("safety check failed: result ${frame.resultCode}"))) + } + } + } + result.success(success).enacted(success) + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.safetyCheck FAILED", e) + _safetyProgress.tryEmit(SafetyProgress.Error(e)) + result.success(false).enacted(false).comment(e.message ?: "error") + } + } + + /** + * Alarm clear: map the cause to the wire alarm-type byte, send [AlarmClearCommand] (0x47 → 0xA7), + * and on `resultCode == 0` reuse the use case's remove-alarm persist. + */ + private fun runAlarmClear(command: CmdAlarmClear): PumpEnactResult { + val result = pumpEnactResultProvider.get() + val address = carelevoPatch.getPatchInfoAddress() + ?: return result.success(false).enacted(false).comment("no patch address") + return try { + val alarmTypeByte = alarmClearRequestUseCase.commandAlarmType(command.alarmCause) + val cause = command.alarmCause.code ?: 0 + val response = runBlocking { + bleSession.runSingle(address, AlarmClearCommand(alarmTypeByte, cause)) + } + val cleared = response.resultCode == RESULT_SUCCESS + val persisted = if (cleared) alarmClearRequestUseCase.persistAlarmCleared(command.alarmId) else false + aapsLogger.info(LTag.PUMPCOMM, "newBle.alarmClear cleared=$cleared result=${response.resultCode} persisted=$persisted") + val ok = cleared && persisted + result.success(ok).enacted(ok) + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.alarmClear FAILED", e) + result.success(false).enacted(false).comment(e.message ?: "error") + } + } + + /** + * Alarm-triggered patch discard: send [PatchDiscardCommand] (0x36) then reuse the use case's DB + * cleanup (ack + reset sync flags + delete infusion/patch). Unlike the plain [runDiscard], this does + * NOT unbond (no `discardTeardown`). + */ + private fun runAlarmClearPatchDiscard(command: CmdAlarmClearPatchDiscard): PumpEnactResult { + val result = pumpEnactResultProvider.get() + val address = carelevoPatch.getPatchInfoAddress() + ?: return result.success(false).enacted(false).comment("no patch address") + return try { + val response = runBlocking { + bleSession.runSingle(address, PatchDiscardCommand()) + } + val discarded = response.resultCode == RESULT_SUCCESS + val persisted = if (discarded) alarmClearPatchDiscardUseCase.persistAlarmDiscarded(command.alarmId) else false + aapsLogger.info(LTag.PUMPCOMM, "newBle.alarmClearPatchDiscard discarded=$discarded result=${response.resultCode} persisted=$persisted") + val ok = discarded && persisted + result.success(ok).enacted(ok) + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.alarmClearPatchDiscard FAILED", e) + result.success(false).enacted(false).comment(e.message ?: "error") + } + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/command/CmdSafetyCheck.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/command/CmdSafetyCheck.kt new file mode 100644 index 000000000000..c7c47951e502 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/command/CmdSafetyCheck.kt @@ -0,0 +1,13 @@ +package app.aaps.pump.carelevo.command + +import app.aaps.core.interfaces.queue.CustomCommand + +/** + * Activation safety-check routed through the AAPS CommandQueue so it gets the queue's managed + * connect-before-execute / reconnect lifecycle (instead of the wizard doing a direct BLE call that + * silently no-ops when the link has dropped). + */ +class CmdSafetyCheck : CustomCommand { + + override val statusDescription: String = "SAFETY CHECK" +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/CarelevoAlarmActionHandler.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/CarelevoAlarmActionHandler.kt new file mode 100644 index 000000000000..783e96f82de0 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/CarelevoAlarmActionHandler.kt @@ -0,0 +1,298 @@ +package app.aaps.pump.carelevo.common + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.coordinator.CarelevoAlarmClearCoordinator +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType +import app.aaps.pump.carelevo.domain.usecase.alarm.CarelevoAlarmInfoUseCase +import app.aaps.pump.carelevo.presentation.model.AlarmEvent +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * SINGLE owner of the in-memory active-alarm queue and of the per-cause alarm-clear state machine, + * shared by BOTH clear surfaces: the Android notification action ([CarelevoAlarmNotifier]) and the + * in-app full-screen alarm ([app.aaps.pump.carelevo.presentation.viewmodel.CarelevoAlarmViewModel], + * which delegates here and keeps only sound/UI concerns). + * + * Persistence rule: an alarm that has been successfully handled is REMOVED from the persisted + * store ([CarelevoAlarmInfoUseCase.acknowledgeAlarm]) as well as from [alarmQueue] — otherwise it + * resurrects on the next cold load or foreground refresh. + */ +@Singleton +class CarelevoAlarmActionHandler @Inject constructor( + private val aapsLogger: AAPSLogger, + private val aapsSchedulers: AapsSchedulers, + private val commandQueue: CommandQueue, + private val alarmUseCase: CarelevoAlarmInfoUseCase, + private val alarmClearCoordinator: CarelevoAlarmClearCoordinator +) { + + // App-lifetime scope (this is a @Singleton): drives the suspend queue-routed alarm-clear ops. + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val _alarmQueue = MutableStateFlow>(emptyList()) + val alarmQueue = _alarmQueue.asStateFlow() + + private val _alarmQueueEmptyEvent = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val alarmQueueEmptyEvent = _alarmQueueEmptyEvent.asSharedFlow() + + /** + * UI requests the state machine cannot execute itself (launch the Bluetooth-enable intent, + * show a failure message). Collected by the in-app alarm host; a request that fires while no + * UI is mounted is intentionally lossy (DROP_OLDEST) — the alarm itself stays queued. + */ + private val _uiRequests = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 4, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val uiRequests = _uiRequests.asSharedFlow() + + /** The alarm most recently entering the clear flow (used by the UI for status text). */ + var alarmInfo: CarelevoAlarmInfo? = null + + private val compositeDisposable = CompositeDisposable() + + fun observeAlarms() = + alarmUseCase.observeAlarms() + .map { it.orElse(emptyList()) } + + fun getAlarmsOnce(): Single> = + alarmUseCase.getAlarmsOnce() + .map { it.orElse(emptyList()) } + + /** (Re)load the persisted active alarms into [alarmQueue], most severe tier first. */ + fun loadActiveAlarms() { + compositeDisposable += getAlarmsOnce() + .subscribeOn(aapsSchedulers.io) + .observeOn(aapsSchedulers.main) + .subscribe( + { alarms -> + val sorted = alarms + .filter { !it.isAcknowledged } + .sortedWith(compareBy { it.alarmType.code }.thenBy { it.createdAt }) + _alarmQueue.value = sorted + if (sorted.isEmpty()) emitQueueEmpty() + }, + { e -> aapsLogger.error(LTag.PUMPCOMM, "loadActiveAlarms.error", e) } + ) + } + + fun triggerEvent(event: AlarmEvent) { + when (event) { + is AlarmEvent.ClearAlarm -> startAlarmClearProcess(event.info) + else -> Unit + } + } + + /** + * The per-cause dispatch for a user-confirmed alarm. Branch groups covering the full union of + * [AlarmCause]: + * - resume-timeout → clear on patch, ack, resume delivery + * - clearable ALERT/NOTICE causes → clear on patch, ack; on failure fall back to local ack + * - WARNING causes (patch fault) → discard the patch (graceful when reachable, local force-quit + * when not) — the patch is being abandoned + * - auto-off WARNING → clear + resume (delivery was only paused) + * - Bluetooth-off ALERT → local ack + request BT enable + reconnect + * - informational LGS/BG/timezone notices → no patch op + */ + private fun startAlarmClearProcess(info: CarelevoAlarmInfo) { + alarmInfo = info + val alarmType = info.alarmType + val alarmCause = info.cause + + aapsLogger.debug(LTag.PUMPCOMM, "startAlarmClearProcess alarmType=$alarmType, alarmCause=$alarmCause") + + // The BLE ops go through the CommandQueue (it reconnects a resting pump first); an + // unreachable patch is handled by the on-failure fallback path rather than a pre-gate. + when (alarmCause) { + AlarmCause.ALARM_ALERT_RESUME_INSULIN_DELIVERY_TIMEOUT, + AlarmCause.ALARM_WARNING_NOT_USED_APP_AUTO_OFF -> { + scope.launch { + // Resume only if the alarm was actually cleared on the patch — do not resume delivery + // into an unresolved alarm condition. + if (alarmClearCoordinator.clearAlarmOnPatch(info)) { + acknowledgeAndRemoveAlarm(info.alarmId) + alarmClearCoordinator.resumeInfusion() + } else { + _uiRequests.emit(AlarmEvent.ShowToastMessage(R.string.alarm_feat_msg_check_patch_connect)) + } + } + } + + AlarmCause.ALARM_ALERT_OUT_OF_INSULIN, + AlarmCause.ALARM_ALERT_PATCH_EXPIRED_PHASE_1, + AlarmCause.ALARM_ALERT_PATCH_EXPIRED_PHASE_2, + AlarmCause.ALARM_ALERT_APP_NO_USE, + AlarmCause.ALARM_ALERT_PATCH_APPLICATION_INCOMPLETE, + AlarmCause.ALARM_ALERT_LOW_BATTERY, + AlarmCause.ALARM_ALERT_INVALID_TEMPERATURE, + AlarmCause.ALARM_NOTICE_LOW_INSULIN, + AlarmCause.ALARM_NOTICE_PATCH_EXPIRED, + AlarmCause.ALARM_NOTICE_ATTACH_PATCH_CHECK -> { + scope.launch { + if (alarmClearCoordinator.clearAlarmOnPatch(info)) acknowledgeAndRemoveAlarm(info.alarmId) + else startAlarmAlertAbnormalClearProcess(info, alarmCause) + } + } + + AlarmCause.ALARM_ALERT_BLE_NOT_CONNECTED, + AlarmCause.ALARM_ALERT_BLUETOOTH_OFF -> { + startAlarmAlertAbnormalClearProcess(info, alarmCause) + } + + AlarmCause.ALARM_WARNING_LOW_INSULIN, + AlarmCause.ALARM_WARNING_PATCH_EXPIRED_PHASE_1, + AlarmCause.ALARM_WARNING_INVALID_TEMPERATURE, + AlarmCause.ALARM_WARNING_BLE_NOT_CONNECTED, + AlarmCause.ALARM_WARNING_INCOMPLETE_PATCH_SETTING, + AlarmCause.ALARM_WARNING_SELF_DIAGNOSIS_FAILED, + AlarmCause.ALARM_WARNING_PATCH_EXPIRED, + AlarmCause.ALARM_WARNING_PATCH_ERROR, + AlarmCause.ALARM_WARNING_PUMP_CLOGGED, + AlarmCause.ALARM_WARNING_NEEDLE_INSERTION_ERROR, + AlarmCause.ALARM_WARNING_LOW_BATTERY -> { + discardPatchForAlarm(info) + } + + AlarmCause.ALARM_NOTICE_BG_CHECK, + AlarmCause.ALARM_NOTICE_TIME_ZONE_CHANGED, + AlarmCause.ALARM_NOTICE_LGS_START, + AlarmCause.ALARM_NOTICE_LGS_FINISHED_DISCONNECTED_PATCH_OR_CGM, + AlarmCause.ALARM_NOTICE_LGS_FINISHED_PAUSE_LGS, + AlarmCause.ALARM_NOTICE_LGS_FINISHED_TIME_OVER, + AlarmCause.ALARM_NOTICE_LGS_FINISHED_OFF_LGS, + AlarmCause.ALARM_NOTICE_LGS_FINISHED_HIGH_BG, + AlarmCause.ALARM_NOTICE_LGS_FINISHED_UNKNOWN, + AlarmCause.ALARM_NOTICE_LGS_NOT_WORKING -> { + // Informational — no patch-side state to clear; remove locally on confirm. + acknowledgeAndRemoveAlarm(info.alarmId) + } + + AlarmCause.ALARM_UNKNOWN -> { + if (alarmType == AlarmType.WARNING) discardPatchForAlarm(info) + } + } + } + + /** WARNING-tier handling: the patch is faulty and being abandoned. */ + private fun discardPatchForAlarm(info: CarelevoAlarmInfo) { + if (alarmClearCoordinator.isPatchReachable()) { + // Reachable: tell the patch to discard (via the queue), then abandon it locally. + scope.launch { + alarmClearCoordinator.discardOnAlarm(info) + startAlarmClearPatchForceQuitProcess() + } + } else { + // Unreachable/faulty patch: abandon locally NOW — do not wait on the queue's + // connect-loop (up to ~119s) while a critical warning keeps sounding. + startAlarmClearPatchForceQuitProcess() + } + } + + /** + * The patch could not be reached (or the link itself is the alarm): acknowledge locally so the + * user is not held hostage by an unclearable alarm, then run any cause-specific recovery. + */ + private fun startAlarmAlertAbnormalClearProcess(info: CarelevoAlarmInfo, alarmCause: AlarmCause) { + compositeDisposable += alarmUseCase.acknowledgeAlarm(info.alarmId) + .subscribeOn(aapsSchedulers.io) + .observeOn(aapsSchedulers.io) + .subscribe( + { + when (alarmCause) { + AlarmCause.ALARM_ALERT_BLUETOOTH_OFF -> { + // Keep the queue entry visible until the link is confirmed back; + // ask the UI to fire the system Bluetooth-enable intent. + startReconnect(info.alarmId) + scope.launch { _uiRequests.emit(AlarmEvent.RequestBluetoothEnable) } + } + + else -> removeFromQueue(info.alarmId) + } + }, + { error -> aapsLogger.error(LTag.PUMPCOMM, "abnormalClear.acknowledge failed cause=$alarmCause", error) } + ) + } + + private fun startReconnect(alarmId: String) { + // Reconnect + refresh through the queue (managed connect-before-execute) rather than a raw BLE + // connect that would fight the queue-owned lifecycle. Ack the alarm once the link is confirmed. + scope.launch { + if (commandQueue.readStatus("Bluetooth re-enabled after alarm").success) { + aapsLogger.debug(LTag.PUMPCOMM, "reconnect readStatus success") + acknowledgeAndRemoveAlarm(alarmId) + } else { + aapsLogger.debug(LTag.PUMPCOMM, "reconnect readStatus failed") + } + } + } + + private fun startAlarmClearPatchForceQuitProcess() { + alarmClearCoordinator.forceQuitTeardown { clearAllAlarms() } + } + + /** + * Handled alarm: remove from the persisted store AND from the in-memory queue. Without the + * store removal a cleared alarm resurrects on the next cold load / foreground refresh. + */ + fun acknowledgeAndRemoveAlarm(alarmId: String) { + compositeDisposable += alarmUseCase.acknowledgeAlarm(alarmId) + .subscribeOn(aapsSchedulers.io) + .subscribe( + { removeFromQueue(alarmId) }, + { e -> + aapsLogger.error(LTag.PUMPCOMM, "acknowledgeAndRemoveAlarm.persist failed id=$alarmId", e) + // Still drop it from the visible queue — the user confirmed it; worst case it + // reappears on the next refresh instead of being stuck on screen forever. + removeFromQueue(alarmId) + } + ) + } + + private fun removeFromQueue(alarmId: String) { + _alarmQueue.value = _alarmQueue.value.filterNot { it.alarmId == alarmId } + if (_alarmQueue.value.isEmpty()) emitQueueEmpty() + } + + fun clearAllAlarms() { + compositeDisposable += alarmUseCase.clearAlarms() + .subscribeOn(aapsSchedulers.io) + .observeOn(aapsSchedulers.io) + .subscribe( + { + _alarmQueue.value = emptyList() + emitQueueEmpty() + }, + { e -> + aapsLogger.error(LTag.PUMPCOMM, "clearAllAlarms.error error=$e") + }) + } + + private fun emitQueueEmpty() { + val ok = _alarmQueueEmptyEvent.tryEmit(Unit) + aapsLogger.debug(LTag.PUMPCOMM, "alarmQueue empty emit=$ok") + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/CarelevoAlarmNotifier.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/CarelevoAlarmNotifier.kt new file mode 100644 index 000000000000..364a9010547f --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/CarelevoAlarmNotifier.kt @@ -0,0 +1,311 @@ +package app.aaps.pump.carelevo.common + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.annotation.StringRes +import androidx.core.app.NotificationCompat +import androidx.core.text.HtmlCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.notifications.NotificationAction +import app.aaps.core.interfaces.notifications.NotificationId +import app.aaps.core.interfaces.notifications.NotificationLevel +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.common.keys.CarelevoIntPreferenceKey +import app.aaps.core.ui.R as CoreUiR +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType.Companion.isCritical +import app.aaps.pump.carelevo.ext.transformNotificationStringResources +import app.aaps.pump.carelevo.presentation.model.AlarmEvent +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Presentation half of the alarm pipeline: observes the persisted active-alarm set (via + * [CarelevoAlarmActionHandler]) and surfaces it — the in-app AAPS "top notification" cards + * ([showTopNotification], with the clear action wired back to the shared state machine), the + * system-tray notification for backgrounded users ([showNotification]), and the [alarms] StateFlow + * the Compose alarm host renders from. The plugin (`CarelevoPumpPlugin.handleAlarms`) decides per + * emission which surface a given alarm set takes; [alarmHostActive] tells it whether the in-app + * host is mounted. + */ +@Singleton +class CarelevoAlarmNotifier @Inject constructor( + private val context: Context, + private val aapsLogger: AAPSLogger, + private val aapsSchedulers: AapsSchedulers, + private val dateUtil: DateUtil, + private val notificationManager: app.aaps.core.interfaces.notifications.NotificationManager, + private val sp: SP, + private val alarmActionHandler: CarelevoAlarmActionHandler +) { + + private val disposables = CompositeDisposable() + private val _alarms = MutableStateFlow>(emptyList()) + val alarms = _alarms.asStateFlow() + private var onAlarmsUpdated: ((List) -> Unit)? = null + private val channelId = "carelevo_alarm_channel" + + /** + * True while the in-app Compose alarm host ([app.aaps.pump.carelevo.compose.alarm.CarelevoAlarmHost]) + * is mounted (user is on the Carelevo screen). The plugin uses this to decide whether a critical + * alarm is already being presented (host shows the full-screen alarm + sound) or must fall back + * to the global `UiInteraction.runAlarm` so a backgrounded/elsewhere user is never left silent. + */ + @Volatile var alarmHostActive: Boolean = false + + fun startObserving( + onAlarmsUpdated: (List) -> Unit + ) { + this.onAlarmsUpdated = onAlarmsUpdated + createNotificationChannel() + + disposables += alarmActionHandler.observeAlarms() + .subscribeOn(aapsSchedulers.io) + .observeOn(aapsSchedulers.main) + .subscribe( + { alarms -> handleAlarmsInternal(alarms) }, + { e -> aapsLogger.error(LTag.PUMPCOMM, "observeAlarms.error error=$e") } + ) + } + + fun refreshAlarms() { + disposables += alarmActionHandler.getAlarmsOnce() + .subscribeOn(aapsSchedulers.io) + .observeOn(aapsSchedulers.main) + .subscribe( + { alarms -> handleAlarmsInternal(alarms) }, + { e -> aapsLogger.error(LTag.PUMPCOMM, "refreshAlarms.error error=$e") } + ) + } + + private fun handleAlarmsInternal(alarms: List) { + aapsLogger.debug(LTag.PUMPCOMM, "handleAlarmsInternal alarms=$alarms") + _alarms.value = alarms + + if (!isInForeground) { + alarms.forEach { alarm -> + showNotification(alarm) + } + } + + onAlarmsUpdated?.invoke(alarms) + } + + fun showTopNotification(alarms: List) { + // CARELEVO_PATCH_ALERT is allowMultiple=true (a fresh instance per post with no replace), so + // clear the previous carelevo cards before re-posting the current set. This gives + // replace-on-refresh semantics while still letting distinct concurrent alarms coexist. + notificationManager.dismiss(NotificationId.CARELEVO_PATCH_ALERT) + alarms.forEach { newAlarm -> + val (titleRes, descRes, btnRes) = newAlarm.cause.transformNotificationStringResources() + + val descArgs = buildDescArgsFor(newAlarm) + val desc = buildDescription(descRes, descArgs) + aapsLogger.debug(LTag.PUMPCOMM, "showTopNotification titleRes=$titleRes descArgs=$descArgs desc=$desc") + // Critical tiers get the id's declared URGENT level + alarm sound (the shared + // NotificationManagerImpl gates its ramping alarm on URGENT && soundRes != null) and do + // not auto-expire — an unhandled critical alarm must not disappear on its own. + // Non-critical notices stay NORMAL/silent and also persist until handled. + val critical = newAlarm.alarmType.isCritical() + notificationManager.post( + id = NotificationId.CARELEVO_PATCH_ALERT, + text = context.getString(titleRes) + "\n" + HtmlCompat.fromHtml(desc, HtmlCompat.FROM_HTML_MODE_LEGACY), + level = if (critical) NotificationLevel.URGENT else NotificationLevel.NORMAL, + actions = listOf( + NotificationAction(btnRes) { + alarmActionHandler.triggerEvent(AlarmEvent.ClearAlarm(info = newAlarm)) + } + ), + date = dateUtil.now(), + soundRes = if (critical) CoreUiR.raw.error else null, + validityCheck = null, + ) + } + } + + fun stopObserving() { + disposables.clear() + } + + fun showNotification(alarm: CarelevoAlarmInfo) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + val (titleRes, descRes, _) = alarm.cause.transformNotificationStringResources() + val description = buildNotificationDescription(alarm, descRes) + + val contentPendingIntent = createAlarmActivityPendingIntent() + + val notification = buildNotification( + title = context.getString(titleRes), + description = description, + contentIntent = contentPendingIntent + ) + + notificationManager.notify(alarm.alarmId.hashCode(), notification) + } + + /** Builds the final body text from the alarm metadata and descRes. */ + private fun buildNotificationDescription( + alarm: CarelevoAlarmInfo, + @StringRes descRes: Int? + ): String { + if (descRes == null) return "" + return when (alarm.cause) { + AlarmCause.ALARM_ALERT_OUT_OF_INSULIN, + AlarmCause.ALARM_NOTICE_LOW_INSULIN -> { + val remain = (alarm.value ?: 0).toString() + context.getString(descRes, remain) + } + + AlarmCause.ALARM_NOTICE_PATCH_EXPIRED -> { + formatPatchExpired(descRes, alarm.value ?: 0) + } + + AlarmCause.ALARM_NOTICE_BG_CHECK -> { + val span = formatBgCheckSpan(alarm.value ?: 0) + context.getString(descRes, span) + } + + else -> context.getString(descRes) + } + } + + private fun formatPatchExpired(@StringRes descRes: Int, totalHours: Int): String { + val days = totalHours / 24 + val remainHours = totalHours % 24 + return context.getString(descRes, days, remainHours) + } + + private fun formatBgCheckSpan(totalMinutes: Int): String { + val hours = totalMinutes / 60 + val minutes = totalMinutes % 60 + return if (hours > 0) { + context.getString( + R.string.common_label_unit_value_duration_hour_and_minute, + hours, minutes + ) + } else { + context.getString( + R.string.common_label_unit_value_minute, + minutes + ) + } + } + + private fun buildNotification( + title: String, + description: String, + contentIntent: PendingIntent + ): Notification { + return NotificationCompat.Builder(context, channelId) + .setSmallIcon(app.aaps.core.ui.R.drawable.notif_icon) + .setContentTitle(title) + .setContentText(description) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(contentIntent) + .setAutoCancel(true) + .build() + } + + private fun createAlarmActivityPendingIntent(): PendingIntent { + val launchIntent = + context.packageManager.getLaunchIntentForPackage(context.packageName) + ?.apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED + ) + } + + val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + + return PendingIntent.getActivity( + context, + 0, + launchIntent, + flags + ) + } + + private fun createNotificationChannel() { + // Resource strings — the channel name/description are user-visible in the system's + // notification settings. + val name = context.getString(R.string.carelevo_alarm_channel_name) + val descriptionText = context.getString(R.string.carelevo_alarm_channel_description) + val importance = NotificationManager.IMPORTANCE_HIGH + val channel = NotificationChannel(channelId, name, importance).apply { + description = descriptionText + } + val notificationManager: NotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + + private fun buildDescArgsFor(alarm: CarelevoAlarmInfo): List = when (alarm.cause) { + AlarmCause.ALARM_NOTICE_LOW_INSULIN, + AlarmCause.ALARM_ALERT_OUT_OF_INSULIN -> { + val lowInsulinNoticeAmount = sp.getInt(CarelevoIntPreferenceKey.CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS.key, 30) + listOf((lowInsulinNoticeAmount).toString()) + } + + AlarmCause.ALARM_NOTICE_PATCH_EXPIRED -> { + val expiry = sp.getInt(CarelevoIntPreferenceKey.CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS.key, 116) + aapsLogger.debug(LTag.PUMPCOMM, "buildDescArgsFor alarm=${alarm.value} expiry=$expiry") + val (days, hours) = splitDaysAndHours(expiry) + listOf(days.toString(), hours.toString()) + } + + AlarmCause.ALARM_NOTICE_BG_CHECK -> { + val totalMinutes = alarm.value ?: 0 + listOf(formatBgCheckDuration(totalMinutes)) + } + + else -> emptyList() + } + + private fun buildDescription(@StringRes descRes: Int?, args: List): String { + return descRes?.let { resId -> + if (args.isEmpty()) context.getString(resId) else context.getString(resId, *args.toTypedArray()) + } ?: "" + } + + private fun splitDaysAndHours(totalHours: Int): Pair { + val days = totalHours / 24 + val hours = totalHours % 24 + return days to hours + } + + private fun formatBgCheckDuration(totalMinutes: Int): String { + val hours = totalMinutes / 60 + val minutes = totalMinutes % 60 + return when { + hours > 0 && minutes > 0 -> + context.getString(R.string.common_label_unit_value_duration_hour_and_minute, hours, minutes) + + hours > 0 -> + context.getString(R.string.common_label_unit_value_duration_hour, hours) + + else -> + context.getString(R.string.common_label_unit_value_minute, minutes) + } + } + + val isInForeground: Boolean + get() = ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/CarelevoEventFlow.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/CarelevoEventFlow.kt new file mode 100644 index 000000000000..1314d8864f0e --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/CarelevoEventFlow.kt @@ -0,0 +1,49 @@ +package app.aaps.pump.carelevo.common + +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableSharedFlow +import java.util.concurrent.atomic.AtomicBoolean + +interface EventFlow : Flow { + companion object { + + const val DEFAULT_REPLAY: Int = 1 + } +} + +interface MutableEventFlow : EventFlow, FlowCollector + +@Suppress("FunctionName") +fun MutableEventFlow( + replay: Int = EventFlow.DEFAULT_REPLAY +): MutableEventFlow = EventFlowImpl(replay) + +fun MutableEventFlow.asEventFlow(): EventFlow = ReadOnlyEventFlow(this) + +private class ReadOnlyEventFlow(flow: EventFlow) : EventFlow by flow + +private class EventFlowImpl( + replay: Int +) : MutableEventFlow { + + private val flow: MutableSharedFlow> = MutableSharedFlow(replay = replay) + + @InternalCoroutinesApi + override suspend fun collect(collector: FlowCollector) = flow.collect { slot -> + if (!slot.markConsumed()) { + collector.emit(slot.value) + } + } + + override suspend fun emit(value: T) { + flow.emit(EventFlowSlot(value)) + } +} + +private class EventFlowSlot(val value: T) { + + private val consumed: AtomicBoolean = AtomicBoolean(false) + fun markConsumed(): Boolean = consumed.getAndSet(true) +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/CarelevoObserveReceiver.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/CarelevoObserveReceiver.kt new file mode 100644 index 000000000000..633dc49b3c5e --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/CarelevoObserveReceiver.kt @@ -0,0 +1,42 @@ +package app.aaps.pump.carelevo.common + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Observer + +/** + * Rx wrapper around a [BroadcastReceiver]: each subscription registers a receiver for [filter] and + * emits every matching [Intent]; disposing the subscription unregisters it (via + * [CarelevoReceiverDisposable]). Used by the plugin to observe Bluetooth adapter state changes. + * + * Registered NOT_EXPORTED — the only consumers listen to protected system broadcasts + * (e.g. `BluetoothAdapter.ACTION_STATE_CHANGED`), which the system delivers regardless; nothing + * here should ever be targetable by other apps. + */ +class CarelevoObserveReceiver( + private val context: Context, + private val filter: IntentFilter +) : Observable() { + + override fun subscribeActual(observer: Observer) { + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + intent?.let { + if (filter.matchAction(it.action)) { + observer.onNext(it) + } + } + } + }.apply { + observer.onSubscribe(CarelevoReceiverDisposable(context, this)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + context.registerReceiver(this, filter, Context.RECEIVER_NOT_EXPORTED) + else + context.registerReceiver(this, filter) + } + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/CarelevoPatch.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/CarelevoPatch.kt new file mode 100644 index 000000000000..8463897970bc --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/CarelevoPatch.kt @@ -0,0 +1,537 @@ +package app.aaps.pump.carelevo.common + +import app.aaps.core.data.model.TE +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.profile.Profile +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.rx.events.EventCustomActionsChanged +import app.aaps.core.interfaces.rx.events.EventPumpStatusChanged +import app.aaps.core.interfaces.rx.events.EventRefreshOverview +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.core.keys.DoubleKey +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.pump.carelevo.ble.CarelevoBleTransport +import app.aaps.pump.carelevo.ble.UnsolicitedMessage +import app.aaps.pump.carelevo.ble.data.BleState +import app.aaps.pump.carelevo.ble.data.DeviceModuleState +import app.aaps.pump.carelevo.ble.data.isAvailable +import app.aaps.pump.carelevo.common.keys.CarelevoIntPreferenceKey +import app.aaps.pump.carelevo.common.model.PatchState +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.model.bt.InfusionModeResult.Companion.codeToInfusionModeCommand +import app.aaps.pump.carelevo.domain.model.bt.InfusionModeResult.Companion.commandToCode +import app.aaps.pump.carelevo.domain.model.bt.PumpStateResult.Companion.codeToPumpStateCommand +import app.aaps.pump.carelevo.domain.model.bt.PumpStateResult.Companion.commandToCode +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.model.userSetting.CarelevoUserSettingInfoDomainModel +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType +import app.aaps.pump.carelevo.domain.usecase.alarm.CarelevoAlarmInfoUseCase +import app.aaps.pump.carelevo.domain.usecase.infusion.CarelevoInfusionInfoMonitorUseCase +import app.aaps.pump.carelevo.domain.usecase.infusion.CarelevoPumpResumeUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchInfoMonitorUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchRptInfusionInfoProcessUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.model.CarelevoPatchRptInfusionInfoRequestModel +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoCreateUserSettingInfoUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoUserSettingInfoMonitorUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.model.CarelevoUserSettingInfoRequestModel +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.subjects.BehaviorSubject +import java.time.LocalDateTime +import java.util.Optional +import java.util.UUID +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull +import kotlin.math.abs +import kotlin.math.min + +class CarelevoPatch @Inject constructor( + private val transport: CarelevoBleTransport, + private val aapsSchedulers: AapsSchedulers, + private val rxBus: RxBus, + private val sp: SP, + private val preferences: Preferences, + private val aapsLogger: AAPSLogger, + private val infusionInfoMonitorUseCase: CarelevoInfusionInfoMonitorUseCase, + private val patchInfoMonitorUseCase: CarelevoPatchInfoMonitorUseCase, + private val userSettingInfoMonitorUseCase: CarelevoUserSettingInfoMonitorUseCase, + private val patchRptInfusionInfoProcessUseCase: CarelevoPatchRptInfusionInfoProcessUseCase, + private val createUserSettingInfoUseCase: CarelevoCreateUserSettingInfoUseCase, + private val carelevoAlarmInfoUseCase: CarelevoAlarmInfoUseCase, + private val pumpResumeUseCase: CarelevoPumpResumeUseCase +) { + + private val bleDisposable = CompositeDisposable() + + private val infoDisposable = CompositeDisposable() + + private var _isWorking = false + val isWorking get() = _isWorking + + private val _btState: BehaviorSubject> = BehaviorSubject.create() + val btState get() = _btState + + private val _patchState: BehaviorSubject> = BehaviorSubject.create() + val patchState get() = _patchState + + private val _patchInfo: BehaviorSubject> = BehaviorSubject.create() + val patchInfo get() = _patchInfo + + private val _infusionInfo: BehaviorSubject> = BehaviorSubject.create() + val infusionInfo get() = _infusionInfo + + private val _userSettingInfo: BehaviorSubject> = BehaviorSubject.create() + val userSettingInfo get() = _userSettingInfo + + private val _profile: BehaviorSubject> = BehaviorSubject.create() + val profile get() = _profile + + /** + * Site placement chosen during the activation wizard's site-location step. Read by the needle + * insertion step when it records the CANNULA_CHANGE therapy event. Defaults to NONE (site + * rotation disabled or step skipped). + */ + @Volatile var sitePlacementLocation: TE.Location = TE.Location.NONE + private set + @Volatile var sitePlacementArrow: TE.Arrow = TE.Arrow.NONE + private set + + fun setSitePlacement(location: TE.Location, arrow: TE.Arrow) { + sitePlacementLocation = location + sitePlacementArrow = arrow + } + + private var lastBtState: BleState? = null + + fun initPatch() { + if (!isWorking) { + observePatchInfo() + observeChangeState() + observeInfusionInfo() + observeUserSettingInfo() + _isWorking = true + } + } + + fun initPatchAndAwait(): Completable = + Completable.defer { + initPatch() + patchState + .filter { state -> + state == PatchState.NotConnectedNotBooting || state == PatchState.ConnectedBooted + } + .firstOrError() + .ignoreElement() + } + + /** One-shot wrapper that shares the same init progress across duplicate calls. */ + @Volatile private var inFlightInit: Completable? = null + fun initPatchOnce(): Completable = synchronized(this) { + inFlightInit?.let { return it } + val c = initPatchAndAwait() + .timeout(20, TimeUnit.SECONDS) + .cache() + .doFinally { synchronized(this) { inFlightInit = null } } + inFlightInit = c + c + } + + fun getPatchInfoAddress(): String? { + return patchInfo.value?.getOrNull()?.address + } + + /** + * Per-op sessions leave no resting link to observe, so "connected" collapses to patch validity: + * an activated patch with Bluetooth available is operational ([PatchState.ConnectedBooted] — every + * op dials its own session on demand), an activated patch with Bluetooth off is + * [PatchState.NotConnectedBooted], and no patch is [PatchState.NotConnectedNotBooting]. + */ + fun resolvePatchState(): PatchState { + val isPatchValid = patchInfo.value?.getOrNull() != null + val btAvailable = btState.value?.getOrNull()?.isAvailable() ?: false + + return when { + !isPatchValid -> PatchState.NotConnectedNotBooting + btAvailable -> PatchState.ConnectedBooted + else -> PatchState.NotConnectedBooted + } + } + + private fun observeChangeState() { + aapsLogger.debug(LTag.PUMPCOMM, "observeChangeState called") + bleDisposable += BehaviorSubject.combineLatest( + btState, + patchInfo + ) { _, _ -> + val result = resolvePatchState() + aapsLogger.debug(LTag.PUMPCOMM, "result : $result") + + _patchState.onNext(Optional.ofNullable(result)) + + when (result) { + is PatchState.NotConnectedNotBooting -> { + aapsLogger.debug(LTag.PUMPCOMM, "patch state is no connection") + rxBus.send(EventPumpStatusChanged(EventPumpStatusChanged.Status.DISCONNECTED)) + rxBus.send(EventRefreshOverview("Carelevo connection state", true)) + rxBus.send(EventCustomActionsChanged()) + } + + is PatchState.ConnectedBooted -> { + aapsLogger.debug(LTag.PUMPCOMM, "patch state is ConnectedBooted") + rxBus.send(EventPumpStatusChanged(EventPumpStatusChanged.Status.CONNECTED)) + rxBus.send(EventRefreshOverview("Carelevo connection state", true)) + rxBus.send(EventCustomActionsChanged()) + } + + is PatchState.NotConnectedBooted -> { + aapsLogger.debug(LTag.PUMPCOMM, "patch state is NotConnectedBooted") + } + + else -> { + aapsLogger.debug(LTag.PUMPCOMM, "patch state is disconnected") + rxBus.send(EventPumpStatusChanged(EventPumpStatusChanged.Status.DISCONNECTED)) + } + } + + result + } + .observeOn(aapsSchedulers.io) + .subscribeOn(aapsSchedulers.io) + .doOnComplete { + aapsLogger.debug(LTag.PUMPCOMM, "doOnComplete called") + } + .doOnError { + it.printStackTrace() + aapsLogger.debug(LTag.PUMPCOMM, "doOnError called : $it") + } + .subscribe { + aapsLogger.debug(LTag.PUMPCOMM, "result : $it") + } + } + + fun isBluetoothEnabled(): Boolean { + return btState.value?.getOrNull()?.let { + it.isEnabled == DeviceModuleState.DEVICE_STATE_ON + } ?: false + } + + //=================================================================================================== + fun setProfile(profile: Profile?) { + _profile.onNext(Optional.ofNullable(profile)) + } + + fun checkIsSameProfile(newProfile: Profile?): Boolean { + val setProfile = profile.value?.getOrNull() ?: return false + val a = newProfile ?: return false + val aVals = a.getBasalValues() + val bVals = setProfile.getBasalValues() + + if (aVals.size != bVals.size) return false + + for (i in aVals.indices) { + if (TimeUnit.SECONDS.toMinutes(aVals[i].timeAsSeconds.toLong()) != + TimeUnit.SECONDS.toMinutes(bVals[i].timeAsSeconds.toLong()) + ) return false + + if (!nearlyEqual(aVals[i].value.toFloat(), bVals[i].value.toFloat())) return false + } + return true + } + + private fun nearlyEqual(a: Float, b: Float, epsilon: Float = 1e-3f): Boolean { + val absA = abs(a) + val absB = abs(b) + val diff = abs(a - b) + return if (a == b) { + true + } else if (a == 0f || b == 0f || absA + absB < java.lang.Float.MIN_NORMAL) { + diff < epsilon * java.lang.Float.MIN_NORMAL + } else { + diff / min(absA + absB, Float.MAX_VALUE) < epsilon + } + } + + /** + * btState source: the plugin's Bluetooth broadcast receiver (adapter on/off) plus its startup + * seed. Raises the Bluetooth-off alarm on the ON→OFF edge. + */ + fun onBluetoothStateChanged(state: BleState) { + aapsLogger.debug(LTag.PUMPCOMM, "btState : $state") + if (state.isEnabled == DeviceModuleState.DEVICE_STATE_OFF && + lastBtState != null && lastBtState?.isEnabled != DeviceModuleState.DEVICE_STATE_OFF + ) { + handleAlarm("alert", value = null, cause = AlarmCause.ALARM_ALERT_BLUETOOTH_OFF) + } + lastBtState = state + _btState.onNext(Optional.of(state)) + } + + fun releasePatch() { + flushPatchInformation() + } + + fun flushPatchInformation() { + // Clear cached patch/infusion info. This resets isCheckScreen (which otherwise keeps the + // wizard latched to SAFETY_CHECK after a mid-activation discard) and immediately drops + // patchState to NotConnectedNotBooting. There is no resting GATT to tear down — sessions are per-op. + _patchInfo.onNext(Optional.empty()) + _infusionInfo.onNext(Optional.empty()) + } + + private val discardInProgress = AtomicBoolean(false) + + /** + * Full BLE teardown for a discarded patch: remove the OS bond, then [releasePatch] (clear cached + * patch info). Single-flight — if a teardown is already running (e.g. a queued CmdDiscard racing + * the ViewModel force-discard fallback), the second caller is skipped. Self-contained: swallows + * teardown errors, so callers report the already-decided discard result unaffected. + */ + fun discardTeardown() { + if (!discardInProgress.compareAndSet(false, true)) { + aapsLogger.debug(LTag.PUMPCOMM, "discardTeardown skipped (already in progress)") + return + } + // Unbond and flush independently: a failed unbond (adapter gone, bond already removed) + // must never leave the discarded patch's cached info behind — a stale record would keep + // patchState at ConnectedBooted and re-latch the wizard's check screens. + try { + getPatchInfoAddress()?.let { transport.adapter.removeBond(it.uppercase()) } + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "discardTeardown unbond error", e) + } + try { + releasePatch() + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "discardTeardown release error", e) + } finally { + discardInProgress.set(false) + } + } + + /** + * Persist an infusion-info report decoded by the [app.aaps.pump.carelevo.ble.BleClient] stack. + * [pumpStateRaw]/[modeRaw] are the raw 0x91 bytes; they are normalized through the + * `codeTo…().commandToCode()` round-trip the persisted codes require, so the persisted values + * are byte-for-byte identical to a full decode. + */ + fun applyInfusionInfoReport( + runningMinutes: Int, + remains: Double, + infusedTotalBasalAmount: Double, + infusedTotalBolusAmount: Double, + pumpStateRaw: Int, + modeRaw: Int + ) { + dispatchInfusionInfo( + CarelevoPatchRptInfusionInfoRequestModel( + runningMinute = runningMinutes, + remains = remains, + infusedTotalBasalAmount = infusedTotalBasalAmount, + infusedTotalBolusAmount = infusedTotalBolusAmount, + pumpState = pumpStateRaw.codeToPumpStateCommand().commandToCode(), + mode = modeRaw.codeToInfusionModeCommand().commandToCode(), + currentInfusedProgramVolume = 0.0, // not persisted by the process use case + realInfusedTime = 0 + ) + ) + } + + private fun dispatchInfusionInfo(requestModel: CarelevoPatchRptInfusionInfoRequestModel) { + bleDisposable += patchRptInfusionInfoProcessUseCase.execute(requestModel) + .timeout(3, TimeUnit.SECONDS) + .observeOn(aapsSchedulers.io) + .subscribeOn(aapsSchedulers.io) + .subscribe() + } + + /** + * Raise a patch alarm locally (alarm DB + notifier pipeline). Public seam: the long-lived + * `unsolicitedEvents` bridge feeds pump-pushed alert/warning/notice frames here; today it is + * used for the Bluetooth-off alert. + */ + fun handleAlarm(modelType: String, value: Int?, cause: AlarmCause) { + aapsLogger.debug(LTag.PUMPCOMM, "$modelType report : $value, $cause") + val info = CarelevoAlarmInfo( + // UUID, not a millisecond timestamp — two alarms raised in the same millisecond (rapid + // BLE flapping) would otherwise collide and one would silently overwrite the other. + alarmId = UUID.randomUUID().toString(), + alarmType = cause.alarmType, + cause = cause, + value = value, + createdAt = LocalDateTime.now().toString(), + updatedAt = LocalDateTime.now().toString(), + isAcknowledged = false + ) + bleDisposable += carelevoAlarmInfoUseCase.upsertAlarm(info) + .subscribeOn(aapsSchedulers.io) + .observeOn(aapsSchedulers.main) + .subscribe( + { aapsLogger.debug(LTag.PUMPCOMM, "handleAlarm upsert complete") }, + { e -> aapsLogger.error(LTag.PUMPCOMM, "handleAlarm upsert error", e) } + ) + } + + /** + * Reconcile local state to "resumed" after a timed pump-stop ends. The patch auto-resumes at the stop + * duration, but with per-op sessions there is no live link to receive its stop-report push, and a 0x91 + * status read does not carry the suspend flag (see `CarelevoPatchRptInfusionInfoProcessUseCase`) — so + * without this, [CarelevoPatchInfoDomainModel.isStopped] would stay latched until an app-initiated + * resume. Called by the plugin's expiry watchdog and by [onUnsolicited] when a stop/basal-restart + * report is caught mid-session. Repository writes are synchronous, so callers invoke it off the main + * thread. Idempotent — a no-op when the pump is already running. + */ + fun reconcileAutoResumed() { + // Only act when the pump is currently marked stopped — there is nothing to resume otherwise. This + // guards against a basal-restart report (0x88) that fires during normal delivery, or that races a + // stop command: an unconditional persistResumed would clear a legitimate suspend. + if (patchInfo.value?.getOrNull()?.isStopped != true) { + aapsLogger.debug(LTag.PUMPCOMM, "auto-resume reconcile skipped: pump not stopped") + return + } + val ok = pumpResumeUseCase.persistResumed() + aapsLogger.info(LTag.PUMPCOMM, "auto-resume reconcile: persistResumed=$ok") + if (!ok) return + rxBus.send(EventPumpStatusChanged(EventPumpStatusChanged.Status.CONNECTED)) + rxBus.send(EventRefreshOverview("Carelevo auto-resume", true)) + } + + /** + * Route a patch-pushed [UnsolicitedMessage] (a notification that matched no in-flight request) by + * opcode — the bridge the BLE migration dropped. Stop-report (0x8A) / basal-restart (0x88) reconcile + * the resumed state; warning/alert/notice pushes (0xA1/0xA2/0xA3) decode to an [AlarmCause] and raise + * the alarm, reusing the pre-migration mapping. Runs on the session's IO scope and must NOT start a new + * BLE session (the session mutex is held for the whole call — a nested session self-deadlocks); + * [reconcileAutoResumed] and [handleAlarm] only persist, so this is safe. + */ + fun onUnsolicited(msg: UnsolicitedMessage) { + when (msg.opcode) { + RPT_PUMP_STOP, RPT_BASAL_RESTART -> { + aapsLogger.info(LTag.PUMPCOMM, "unsolicited resume report ${hex(msg.opcode)} -> reconcile") + reconcileAutoResumed() + } + + RPT_WARNING -> raiseAlarm("warning", AlarmType.WARNING, msg.payload) + RPT_ALERT -> raiseAlarm("alert", AlarmType.ALERT, msg.payload) + RPT_NOTICE -> raiseAlarm("notice", AlarmType.NOTICE, msg.payload) + + else -> + aapsLogger.debug(LTag.PUMPCOMM, "unsolicited frame ${hex(msg.opcode)} (${msg.payload.size}B) unhandled") + } + } + + /** Decode a pushed alarm frame `[opcode][cause][value]` to an [AlarmCause] and raise it via [handleAlarm]. */ + private fun raiseAlarm(modelType: String, type: AlarmType, payload: ByteArray) { + val causeCode = payload.getOrNull(1)?.toUByte()?.toInt() + val value = payload.getOrNull(2)?.toUByte()?.toInt() + handleAlarm(modelType, value, AlarmCause.fromTypeAndCode(type, causeCode)) + } + + private fun observeInfusionInfo() { + infoDisposable += infusionInfoMonitorUseCase.execute() + .subscribeOn(aapsSchedulers.io) + .observeOn(aapsSchedulers.main) + .subscribe { response -> + when (response) { + is ResponseResult.Success -> { + val result = response.data as CarelevoInfusionInfoDomainModel? + aapsLogger.debug(LTag.PUMPCOMM, "response success result ==> $result") + _infusionInfo.onNext(Optional.ofNullable(result)) + } + + is ResponseResult.Error -> { + aapsLogger.error(LTag.PUMPCOMM, "response error : ${response.e}") + } + + else -> { + aapsLogger.error(LTag.PUMPCOMM, "response failed") + } + } + } + } + + private fun observePatchInfo() { + infoDisposable += patchInfoMonitorUseCase.execute() + .subscribeOn(aapsSchedulers.io) + .observeOn(aapsSchedulers.io) + .subscribe { response -> + when (response) { + is ResponseResult.Success -> { + val result = response.data as CarelevoPatchInfoDomainModel? + aapsLogger.debug(LTag.PUMPCOMM, "response success result ==> ${result?.needleFailedCount}") + _patchInfo.onNext(Optional.ofNullable(result)) + } + + is ResponseResult.Error -> { + aapsLogger.debug(LTag.PUMPCOMM, "response error : ${response.e}") + } + + else -> { + aapsLogger.debug(LTag.PUMPCOMM, "response failed") + } + } + } + } + + private fun observeUserSettingInfo() { + infoDisposable += userSettingInfoMonitorUseCase.execute() + .subscribeOn(aapsSchedulers.io) + .observeOn(aapsSchedulers.main) + .subscribe { response -> + when (response) { + is ResponseResult.Success -> { + val result = response.data as CarelevoUserSettingInfoDomainModel? + aapsLogger.debug(LTag.PUMPCOMM, "response success result ==> $result") + _userSettingInfo.onNext(Optional.ofNullable(result)) + if (result == null) { + createUserSettingInfo() + } + } + + is ResponseResult.Error -> { + aapsLogger.error(LTag.PUMPCOMM, "response error : ${response.e}") + } + + else -> { + aapsLogger.error(LTag.PUMPCOMM, "response failed") + } + } + } + } + + private fun createUserSettingInfo() { + // Read prefs + build the request on IO: createUserSettingInfo() runs from observeUserSettingInfo's + // Main-thread subscribe, and SharedPreferences reads on the main thread risk an ANR. + infoDisposable += Single.fromCallable { + CarelevoUserSettingInfoRequestModel( + lowInsulinNoticeAmount = sp.getInt(CarelevoIntPreferenceKey.CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS.key, 30), + maxBasalSpeed = 15.0, + maxBolusDose = preferences.get(DoubleKey.SafetyMaxBolus) + ) + } + .subscribeOn(aapsSchedulers.io) + .flatMap { createUserSettingInfoUseCase.execute(it) } + .subscribe() + } + + private companion object { + + // Unsolicited report opcodes (byte 0). Stop/basal-restart are the auto-resume signals; the + // 0xA1/0xA2/0xA3 message reports are the pushed alarms (severity tier via AlarmType). + private const val RPT_BASAL_RESTART: Byte = 0x88.toByte() // CMD_BASAL_RESTART_RPT + private const val RPT_PUMP_STOP: Byte = 0x8A.toByte() // CMD_PUMP_STOP_RPT (stop window ended) + private const val RPT_WARNING: Byte = 0xA1.toByte() // CMD_WARNING_MSG_RPT + private const val RPT_ALERT: Byte = 0xA2.toByte() // CMD_ALERT_MSG_RPT + private const val RPT_NOTICE: Byte = 0xA3.toByte() // CMD_NOTICE_MSG_RPT + + private fun hex(b: Byte) = "0x%02X".format(b.toInt() and 0xFF) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/CarelevoReceiverDisposable.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/CarelevoReceiverDisposable.kt new file mode 100644 index 000000000000..87a35f568e1b --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/CarelevoReceiverDisposable.kt @@ -0,0 +1,29 @@ +package app.aaps.pump.carelevo.common + +import android.content.BroadcastReceiver +import android.content.Context +import io.reactivex.rxjava3.disposables.Disposable + +/** + * Rx [Disposable] that unregisters [receiver] on dispose — the teardown half of + * [CarelevoObserveReceiver]'s register-on-subscribe, preventing the classic leaked-receiver bug + * when the plugin's subscription is cleared in `onStop()`. + */ +class CarelevoReceiverDisposable( + private val context: Context, + private val receiver: BroadcastReceiver +) : Disposable { + + private var isDisposed = false + + @Synchronized + override fun dispose() { + context.unregisterReceiver(receiver) + isDisposed = true + } + + @Synchronized + override fun isDisposed(): Boolean { + return isDisposed + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/keys/CarelevoBooleanPreferenceKey.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/keys/CarelevoBooleanPreferenceKey.kt new file mode 100644 index 000000000000..d103bdf44a3f --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/keys/CarelevoBooleanPreferenceKey.kt @@ -0,0 +1,24 @@ +package app.aaps.pump.carelevo.common.keys + +import app.aaps.core.keys.interfaces.BooleanPreferenceKey +import app.aaps.pump.carelevo.R + +enum class CarelevoBooleanPreferenceKey( + override val key: String, + override val defaultValue: Boolean, + override val titleResId: Int = 0, + override val calculatedDefaultValue: Boolean = false, + override val engineeringModeOnly: Boolean = false, + override val defaultedBySM: Boolean = false, + override val showInApsMode: Boolean = true, + override val showInNsClientMode: Boolean = true, + override val showInPumpControlMode: Boolean = true, + override val dependency: BooleanPreferenceKey? = null, + override val negativeDependency: BooleanPreferenceKey? = null, + override val hideParentScreenIfHidden: Boolean = false, + override val exportable: Boolean = true +) : BooleanPreferenceKey { + + CARELEVO_BUZZER_REMINDER(key = "CARELEVO_BUZZER_REMINDER", defaultValue = false, titleResId = R.string.carelevo_patch_buzzer_alarm_title), + CARELEVO_CAGE_DEFAULT_APPLIED(key = "carelevo_cage_default_applied", defaultValue = false, titleResId = 0), +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/keys/CarelevoIntPreferenceKey.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/keys/CarelevoIntPreferenceKey.kt new file mode 100644 index 000000000000..98d063659be6 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/keys/CarelevoIntPreferenceKey.kt @@ -0,0 +1,40 @@ +package app.aaps.pump.carelevo.common.keys + +import app.aaps.core.keys.PreferenceType +import app.aaps.core.keys.interfaces.BooleanPreferenceKey +import app.aaps.core.keys.interfaces.IntPreferenceKey +import app.aaps.pump.carelevo.R + +enum class CarelevoIntPreferenceKey( + override val key: String, + override val defaultValue: Int, + override val titleResId: Int = 0, + override val min: Int = Int.MIN_VALUE, + override val max: Int = Int.MAX_VALUE, + override val preferenceType: PreferenceType = PreferenceType.TEXT_FIELD, + override val entries: Map = emptyMap(), + override val calculatedDefaultValue: Boolean = false, + override val engineeringModeOnly: Boolean = false, + override val defaultedBySM: Boolean = false, + override val showInApsMode: Boolean = true, + override val showInNsClientMode: Boolean = true, + override val showInPumpControlMode: Boolean = true, + override val dependency: BooleanPreferenceKey? = null, + override val negativeDependency: BooleanPreferenceKey? = null, + override val hideParentScreenIfHidden: Boolean = false, + override val exportable: Boolean = true +) : IntPreferenceKey { + + CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS( + "CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS", + 116, + R.string.carelevo_patch_expiration_reminders_title_value, + preferenceType = PreferenceType.LIST + ), + CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS( + "CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS", + 30, + R.string.carelevo_low_reservoir_reminders_title_value, + preferenceType = PreferenceType.LIST + ) +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/model/CarelevoEvent.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/model/CarelevoEvent.kt new file mode 100644 index 000000000000..d0c84d92492f --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/model/CarelevoEvent.kt @@ -0,0 +1,8 @@ +package app.aaps.pump.carelevo.common.model + +/** + * Marker for one-shot UI events delivered through + * [app.aaps.pump.carelevo.common.MutableEventFlow] (consumed exactly once per emission) — e.g. + * the `CarelevoOverviewEvent`/`AlarmEvent` hierarchies in `presentation.model`. + */ +interface Event diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/model/CarelevoState.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/model/CarelevoState.kt new file mode 100644 index 000000000000..8452e748796c --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/common/model/CarelevoState.kt @@ -0,0 +1,33 @@ +package app.aaps.pump.carelevo.common.model + +/** Marker for observable state values exposed by ViewModels/singletons (vs one-shot [Event]s). */ +interface State + +/** Generic busy indicator for screens that show a blocking progress overlay during BLE ops. */ +sealed class UiState : State { + data object Idle : UiState() + data object Loading : UiState() +} + +/** + * Coarse patch lifecycle derived in `CarelevoPatch.resolvePatchState` from the persisted patch + * record ("booted" = activation completed) + live BLE link state. Drives which overview/wizard + * surface is shown. + */ +sealed interface PatchState { + + data object NotConnectedNotBooting : PatchState + data object NotConnectedBooted : PatchState + data object ConnectedNoBooted : PatchState + data object ConnectedBooted : PatchState + +} + +/** Delivery run-state of the pump as last reported by the patch (idle/delivering/suspended). */ +sealed interface PumpState { + + data object Idle : PumpState + data object Start : PumpState + data object Stop : PumpState + +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/CarelevoComposeContent.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/CarelevoComposeContent.kt new file mode 100644 index 000000000000..7a5c0f93312c --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/CarelevoComposeContent.kt @@ -0,0 +1,177 @@ +package app.aaps.pump.carelevo.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.aaps.core.interfaces.configuration.Config +import app.aaps.core.interfaces.configuration.ExternalOptions +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.ui.R as CoreUiR +import app.aaps.core.ui.compose.LocalSnackbarHostState +import app.aaps.core.interfaces.protection.ProtectionCheck +import app.aaps.core.interfaces.protection.ProtectionResult +import app.aaps.core.interfaces.pump.BlePreCheck +import app.aaps.core.interfaces.ui.IconsProvider +import app.aaps.core.ui.compose.ComposablePluginContent +import app.aaps.core.ui.compose.ToolbarConfig +import app.aaps.core.ui.compose.pump.BlePreCheckHost +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.common.CarelevoAlarmNotifier +import app.aaps.pump.carelevo.compose.alarm.CarelevoAlarmHost +import app.aaps.pump.carelevo.compose.patchflow.CarelevoPatchFlowScreen +import app.aaps.pump.carelevo.presentation.type.CarelevoScreenType +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoOverviewViewModel + +class CarelevoComposeContent( + private val aapsLogger: AAPSLogger, + private val carelevoAlarmNotifier: CarelevoAlarmNotifier, + private val protectionCheck: ProtectionCheck, + private val blePreCheck: BlePreCheck, + private val iconsProvider: IconsProvider, + private val config: Config +) : ComposablePluginContent { + + /** + * The emulated patch has no radio behind it, so the real BLE readiness check would fail the + * activation flow before it starts. Mirrors `DanaRSPairWizardViewModel.isEmulating`. + */ + private val isEmulating: Boolean get() = config.isEnabled(ExternalOptions.EMULATE_CARELEVO) + + @Composable + override fun Render( + setToolbarConfig: (ToolbarConfig) -> Unit, + onNavigateBack: () -> Unit, + onSettings: (() -> Unit)? + ) { + val overviewViewModel: CarelevoOverviewViewModel = hiltViewModel() + // Single place this module reads the app-provided CompositionLocal — children get it as an + // explicit parameter (preferred pattern; do not add new .current consumers below). + val snackbarHostState = LocalSnackbarHostState.current + val overviewNavIcon: @Composable () -> Unit = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(CoreUiR.string.back) + ) + } + } + val settingsAction: @Composable RowScope.() -> Unit = { + onSettings?.let { action -> + IconButton(onClick = action) { + Icon( + Icons.Filled.Settings, + contentDescription = stringResource(CoreUiR.string.settings) + ) + } + } + } + + var manualWorkflowScreen by remember { mutableStateOf(null) } + var latchedWorkflowScreen by remember { mutableStateOf(null) } + val checkScreen by overviewViewModel.isCheckScreen.collectAsStateWithLifecycle() + val activeWorkflowScreen = manualWorkflowScreen ?: latchedWorkflowScreen ?: checkScreen + val pluginName = stringResource(R.string.carelevo) + + LaunchedEffect(checkScreen, manualWorkflowScreen) { + if (manualWorkflowScreen == null) { + when (checkScreen) { + CarelevoScreenType.SAFETY_CHECK, + CarelevoScreenType.NEEDLE_INSERTION -> latchedWorkflowScreen = checkScreen + + null -> Unit + else -> latchedWorkflowScreen = null + } + } + } + + LaunchedEffect(activeWorkflowScreen, pluginName) { + if (activeWorkflowScreen == null) { + setToolbarConfig( + ToolbarConfig( + title = pluginName, + navigationIcon = overviewNavIcon, + actions = settingsAction + ) + ) + } + } + + val startWorkflow: (CarelevoScreenType) -> Unit = remember(protectionCheck) { + { screenType -> + if (screenType == CarelevoScreenType.CONNECTION_FLOW_START) { + protectionCheck.requestProtection(ProtectionCheck.Protection.PREFERENCES) { result -> + if (result == ProtectionResult.GRANTED) { + manualWorkflowScreen = screenType + latchedWorkflowScreen = null + } + } + } else { + manualWorkflowScreen = screenType + latchedWorkflowScreen = null + } + } + } + + Box { + when (activeWorkflowScreen) { + null -> { + CarelevoOverviewScreen( + viewModel = overviewViewModel, + snackbarHostState = snackbarHostState, + onStartWorkflow = startWorkflow + ) + } + + CarelevoScreenType.CONNECTION_FLOW_START -> { + if (!isEmulating) { + BlePreCheckHost( + blePreCheck = blePreCheck, + onFailed = { manualWorkflowScreen = null } + ) + } + CarelevoPatchFlowScreen( + screenType = activeWorkflowScreen, + setToolbarConfig = setToolbarConfig, + snackbarHostState = snackbarHostState, + onExitFlow = { + manualWorkflowScreen = null + latchedWorkflowScreen = null + } + ) + } + + else -> { + CarelevoPatchFlowScreen( + screenType = activeWorkflowScreen, + setToolbarConfig = setToolbarConfig, + snackbarHostState = snackbarHostState, + onExitFlow = { + manualWorkflowScreen = null + latchedWorkflowScreen = null + } + ) + } + } + + CarelevoAlarmHost( + aapsLogger = aapsLogger, + carelevoAlarmNotifier = carelevoAlarmNotifier, + iconsProvider = iconsProvider, + snackbarHostState = snackbarHostState + ) + } + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/CarelevoOverviewScreen.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/CarelevoOverviewScreen.kt new file mode 100644 index 000000000000..e156b7bb43ef --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/CarelevoOverviewScreen.kt @@ -0,0 +1,320 @@ +package app.aaps.pump.carelevo.compose + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.aaps.core.ui.R as CoreUiR +import app.aaps.core.ui.compose.StatusLevel +import app.aaps.core.ui.compose.icons.IcLoopPaused +import app.aaps.core.ui.compose.pump.ActionCategory +import app.aaps.core.ui.compose.pump.PumpAction +import app.aaps.core.ui.compose.pump.PumpInfoRow +import app.aaps.core.ui.compose.pump.PumpOverviewScreen +import app.aaps.core.ui.compose.pump.PumpOverviewUiState +import app.aaps.core.ui.compose.pump.StatusBanner +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.common.model.UiState +import app.aaps.pump.carelevo.compose.dialog.CarelevoActionDialog +import app.aaps.pump.carelevo.compose.dialog.CarelevoPumpStopDurationDialog +import app.aaps.pump.carelevo.presentation.model.CarelevoOverviewEvent +import app.aaps.pump.carelevo.presentation.type.CarelevoScreenType +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoOverviewViewModel + +@Composable +internal fun CarelevoOverviewScreen( + viewModel: CarelevoOverviewViewModel, + snackbarHostState: SnackbarHostState, + onStartWorkflow: (CarelevoScreenType) -> Unit +) { + val baseState by viewModel.overviewUiState.collectAsStateWithLifecycle() + val actionState by viewModel.uiState.collectAsStateWithLifecycle() + + var showDiscardDialog by remember { mutableStateOf(false) } + var showResumeDialog by remember { mutableStateOf(false) } + var showSuspendTimePicker by remember { mutableStateOf(false) } + var selectedDurationIndex by remember { mutableIntStateOf(0) } + + val stopDurations = remember { listOf(30, 60, 90, 120, 150, 180, 210, 240) } + val stopDurationLabels = listOf( + stringResource(R.string.carelevo_pump_stop_duration_label_30_min), + stringResource(R.string.carelevo_pump_stop_duration_label_1_hour), + stringResource(R.string.carelevo_pump_stop_duration_label_1_hour_30_min), + stringResource(R.string.carelevo_pump_stop_duration_label_2_hour), + stringResource(R.string.carelevo_pump_stop_duration_label_2_hour_30_min), + stringResource(R.string.carelevo_pump_stop_duration_label_3_hour), + stringResource(R.string.carelevo_pump_stop_duration_label_3_hour_30_min), + stringResource(R.string.carelevo_pump_stop_duration_label_4_hour), + ) + val bluetoothNotEnabledMessage = stringResource(R.string.carelevo_toast_msg_bluetooth_not_enabled) + val notConnectedMessage = stringResource(R.string.carelevo_toast_msg_patch_not_connected) + val discardFailedMessage = stringResource(R.string.carelevo_toast_msg_discard_failed) + val resumeFailedMessage = stringResource(R.string.carelevo_toast_mag_set_basal_resume_fail) + val suspendFailedMessage = stringResource(R.string.carelevo_toast_mag_set_basal_suspend_fail) + val isActionLoading = actionState is UiState.Loading + + LaunchedEffect(viewModel) { + if (!viewModel.isCreated) { + viewModel.observePatchInfo() + viewModel.observePatchState() + viewModel.observeInfusionInfo() + viewModel.observeProfile() + viewModel.setIsCreated(true) + } + viewModel.refreshPatchInfusionInfo() + } + + LaunchedEffect(viewModel) { + viewModel.event.collect { event -> + when (event) { + CarelevoOverviewEvent.ShowMessageBluetoothNotEnabled -> snackbarHostState.showSnackbar(bluetoothNotEnabledMessage) + CarelevoOverviewEvent.ShowMessageCarelevoIsNotConnected -> snackbarHostState.showSnackbar(notConnectedMessage) + CarelevoOverviewEvent.DiscardFailed -> snackbarHostState.showSnackbar(discardFailedMessage) + CarelevoOverviewEvent.ResumePumpFailed -> snackbarHostState.showSnackbar(resumeFailedMessage) + CarelevoOverviewEvent.StopPumpFailed -> snackbarHostState.showSnackbar(suspendFailedMessage) + CarelevoOverviewEvent.ShowPumpResumeDialog -> showResumeDialog = true + CarelevoOverviewEvent.ShowPumpStopDurationSelectDialog -> showSuspendTimePicker = true + CarelevoOverviewEvent.StartConnectionFlow -> onStartWorkflow(CarelevoScreenType.CONNECTION_FLOW_START) + CarelevoOverviewEvent.ShowPumpDiscardDialog -> showDiscardDialog = true + CarelevoOverviewEvent.ClickPumpStopResumeBtn, + CarelevoOverviewEvent.NoAction -> Unit + } + } + } + + if (showDiscardDialog) { + CarelevoActionDialog( + title = stringResource(R.string.carelevo_dialog_patch_discard_message_title), + content = stringResource(R.string.carelevo_dialog_patch_discard_message_desc), + onDismissRequest = { showDiscardDialog = false }, + primaryText = stringResource(R.string.carelevo_btn_confirm), + onPrimaryClick = { + showDiscardDialog = false + viewModel.startDiscardProcess() + }, + secondaryText = stringResource(R.string.carelevo_btn_cancel), + onSecondaryClick = { showDiscardDialog = false } + ) + } + + if (showResumeDialog) { + CarelevoActionDialog( + title = stringResource(R.string.carelevo_pump_resume_title), + content = stringResource(R.string.carelevo_pump_resume_description), + onDismissRequest = { showResumeDialog = false }, + primaryText = stringResource(R.string.carelevo_btn_confirm), + onPrimaryClick = { + showResumeDialog = false + viewModel.startPumpResume() + }, + secondaryText = stringResource(R.string.carelevo_btn_cancel), + onSecondaryClick = { showResumeDialog = false } + ) + } + + if (showSuspendTimePicker) { + CarelevoPumpStopDurationDialog( + options = stopDurations, + labels = stopDurationLabels, + initialIndex = selectedDurationIndex, + onDismissRequest = { + showSuspendTimePicker = false + selectedDurationIndex = 0 + }, + onConfirm = { duration -> + showSuspendTimePicker = false + viewModel.startPumpStopProcess(duration) + selectedDurationIndex = 0 + } + ) + } + + Box { + PumpOverviewScreen( + state = baseState, + customContent = { + Image( + painter = painterResource(id = R.drawable.ic_carelevo_128), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(128.dp), + contentScale = ContentScale.Fit + ) + } + ) + + if (isActionLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)) + // Consume all taps so an in-flight discard/suspend/resume can't be re-fired + // by a second tap on the action underneath. + .pointerInput(Unit) { detectTapGestures { } }, + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + Text( + text = stringResource(CoreUiR.string.loading), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + } +} + +@Preview(showBackground = true, name = "Carelevo Overview Connected") +@Composable +private fun CarelevoOverviewScreenConnectedPreview() { + MaterialTheme { + PumpOverviewScreen( + state = PumpOverviewUiState( + statusBanner = StatusBanner( + text = stringResource(R.string.carelevo_state_connected_value), + level = StatusLevel.NORMAL + ), + infoRows = listOf( + PumpInfoRow( + label = stringResource(R.string.carelevo_bluetooth_state_key), + value = stringResource(R.string.carelevo_state_connected_value) + ), + PumpInfoRow( + label = stringResource(R.string.carelevo_serial_number_key), + value = "04:CD:15:D0:10:05" + ), + PumpInfoRow( + label = stringResource(R.string.carelevo_firmware_version_key), + value = "T165" + ), + PumpInfoRow( + label = stringResource(R.string.carelevo_boot_date_time_key), + value = "2026-04-13 09:00" + ), + PumpInfoRow( + label = stringResource(R.string.carelevo_expiration_key), + value = "2026-04-20 09:00" + ), + PumpInfoRow( + label = stringResource(R.string.carelevo_running_remain_time), + value = "2d 11h 20m" + ), + PumpInfoRow( + label = stringResource(R.string.carelevo_basal_rate_key), + value = stringResource(R.string.common_label_unit_value_dose_per_speed_with_space, 1.2) + ), + PumpInfoRow( + label = stringResource(R.string.carelevo_temp_basal_rate_key), + value = stringResource(R.string.common_label_unit_value_dose_per_speed_with_space, 0.5) + ), + PumpInfoRow( + label = stringResource(R.string.carelevo_insulin_remain_key), + value = "298.0 U" + ), + PumpInfoRow( + label = stringResource(R.string.carelevo_total_insulin_key), + value = stringResource(R.string.common_label_unit_value_dose_with_space, "12.50") + ) + ), + primaryActions = emptyList(), + managementActions = listOf( + PumpAction( + label = stringResource(R.string.carelevo_overview_pump_discard_btn_label), + icon = Icons.Filled.Delete, + category = ActionCategory.MANAGEMENT, + enabled = true, + visible = true, + onClick = {} + ), + PumpAction( + label = stringResource(CoreUiR.string.pump_suspend), + icon = IcLoopPaused, + category = ActionCategory.MANAGEMENT, + enabled = true, + visible = true, + onClick = {} + ) + ) + ), + customContent = { + Image( + painter = painterResource(id = R.drawable.ic_carelevo_128), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(128.dp), + contentScale = ContentScale.Fit + ) + } + ) + } +} + +@Preview(showBackground = true, name = "Carelevo Overview Disconnected") +@Composable +private fun CarelevoOverviewScreenDisconnectedPreview() { + MaterialTheme { + PumpOverviewScreen( + state = PumpOverviewUiState( + statusBanner = StatusBanner( + text = stringResource(R.string.carelevo_state_none_value), + level = StatusLevel.WARNING + ), + infoRows = listOf( + PumpInfoRow( + label = stringResource(R.string.carelevo_bluetooth_state_key), + value = stringResource(R.string.carelevo_state_disconnected_value) + ) + ), + primaryActions = listOf( + PumpAction( + label = stringResource(R.string.carelevo_overview_connect_btn_label), + icon = Icons.Filled.SwapHoriz, + enabled = true, + onClick = {} + ) + ) + ), + customContent = { + Image( + painter = painterResource(id = R.drawable.ic_carelevo_128), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(128.dp), + contentScale = ContentScale.Fit + ) + } + ) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/alarm/CarelevoAlarmHost.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/alarm/CarelevoAlarmHost.kt new file mode 100644 index 000000000000..c0300406af38 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/alarm/CarelevoAlarmHost.kt @@ -0,0 +1,180 @@ +package app.aaps.pump.carelevo.compose.alarm + +import android.app.Activity +import android.bluetooth.BluetoothAdapter +import android.content.Context +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.ui.IconsProvider +import app.aaps.core.ui.R as CoreUiR +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.common.CarelevoAlarmNotifier +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType.Companion.isCritical +import app.aaps.pump.carelevo.ext.transformStringResources +import app.aaps.pump.carelevo.presentation.model.AlarmEvent +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoAlarmViewModel + +@Composable +internal fun CarelevoAlarmHost( + aapsLogger: AAPSLogger, + carelevoAlarmNotifier: CarelevoAlarmNotifier, + iconsProvider: IconsProvider, + snackbarHostState: SnackbarHostState +) { + val context = LocalContext.current + val viewModel: CarelevoAlarmViewModel = hiltViewModel() + val notifierAlarms = carelevoAlarmNotifier.alarms.collectAsStateWithLifecycle().value + val alarmQueue = viewModel.alarmQueue.collectAsStateWithLifecycle().value + var dismissedAlarmId by remember { mutableStateOf(null) } + val currentAlarm = alarmQueue.firstOrNull { it.alarmId != dismissedAlarmId } + var pendingMessageRes by remember { mutableStateOf(null) } + val pendingMessage = pendingMessageRes?.let { stringResource(it) } + + val requestBtLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == Activity.RESULT_OK) { + viewModel.alarmInfo?.let { + aapsLogger.debug(LTag.PUMPCOMM, "bluetooth enabled for alarm=${it.alarmId}") + } + } + } + + // Register as the active in-app alarm surface: while mounted, the plugin suppresses its global + // runAlarm fallback (this host presents the rich alarm screen and starts the sound itself). + DisposableEffect(Unit) { + carelevoAlarmNotifier.alarmHostActive = true + onDispose { carelevoAlarmNotifier.alarmHostActive = false } + } + + LaunchedEffect(notifierAlarms) { + if (notifierAlarms.any { it.alarmType.isCritical() }) { + aapsLogger.debug(LTag.PUMPCOMM, "load alarms from notifier alarms=$notifierAlarms") + viewModel.loadActiveAlarms() + } + } + + LaunchedEffect(viewModel) { + viewModel.event.collect { event -> + when (event) { + AlarmEvent.RequestBluetoothEnable -> { + val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) + requestBtLauncher.launch(enableBtIntent) + } + + is AlarmEvent.ShowToastMessage -> { + pendingMessageRes = event.messageRes + } + + else -> Unit + } + } + } + + LaunchedEffect(pendingMessage) { + pendingMessage?.let { message -> + snackbarHostState.showSnackbar(message) + pendingMessageRes = null + } + } + + LaunchedEffect(currentAlarm?.alarmId) { + if (currentAlarm != null) { + // Hand the presented alarm to the VM first so the alarm dialog carries its title. + viewModel.alarmInfo = currentAlarm + viewModel.triggerEvent(AlarmEvent.StartAlarm) + } + } + + LaunchedEffect(alarmQueue, dismissedAlarmId) { + if (dismissedAlarmId != null && alarmQueue.none { it.alarmId == dismissedAlarmId }) { + dismissedAlarmId = null + } + } + + CarelevoAlarmScreen( + alarm = currentAlarm?.let { buildAlarmUiModel(context, iconsProvider, it) }, + onPrimaryClick = { + currentAlarm?.let { + dismissedAlarmId = it.alarmId + viewModel.triggerEvent(AlarmEvent.ClearAlarm(info = it)) + } + }, + onMuteClick = { + viewModel.triggerEvent(AlarmEvent.Mute) + }, + onMute5MinClick = { + viewModel.triggerEvent(AlarmEvent.Mute5min) + } + ) +} + +private fun buildAlarmUiModel( + context: Context, + iconsProvider: IconsProvider, + alarm: CarelevoAlarmInfo +): CarelevoAlarmUiModel { + val (titleRes, descRes, btnRes) = alarm.cause.transformStringResources() + val descArgs = buildDescArgsFor(context, alarm) + val desc = descRes?.let { resId -> + if (descArgs.isEmpty()) context.getString(resId) else context.getString(resId, *descArgs.toTypedArray()) + } ?: "" + + return CarelevoAlarmUiModel( + appIcon = iconsProvider.getIcon(), + title = context.getString(titleRes), + content = desc, + primaryButtonText = context.getString(btnRes), + muteButtonText = context.getString(CoreUiR.string.mute), + mute5minButtonText = context.getString(CoreUiR.string.mute5min) + ) +} + +private fun buildDescArgsFor(context: Context, alarm: CarelevoAlarmInfo): List = when (alarm.cause) { + AlarmCause.ALARM_NOTICE_LOW_INSULIN, + AlarmCause.ALARM_ALERT_OUT_OF_INSULIN -> { + listOf((alarm.value ?: 0).toString()) + } + + AlarmCause.ALARM_NOTICE_PATCH_EXPIRED -> { + val totalHours = alarm.value ?: 0 + val days = totalHours / 24 + val hours = totalHours % 24 + listOf(days.toString(), hours.toString()) + } + + AlarmCause.ALARM_NOTICE_BG_CHECK -> { + val totalMinutes = alarm.value ?: 0 + val hours = totalMinutes / 60 + val minutes = totalMinutes % 60 + listOf( + when { + hours > 0 && minutes > 0 -> + context.getString(R.string.common_label_unit_value_duration_hour_and_minute, hours, minutes) + + hours > 0 -> + context.getString(R.string.common_label_unit_value_duration_hour, hours) + + else -> + context.getString(R.string.common_label_unit_value_minute, minutes) + } + ) + } + + else -> emptyList() +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/alarm/CarelevoAlarmScreen.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/alarm/CarelevoAlarmScreen.kt new file mode 100644 index 000000000000..ddd16b66ee02 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/alarm/CarelevoAlarmScreen.kt @@ -0,0 +1,178 @@ +package app.aaps.pump.carelevo.compose.alarm + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.aaps.core.ui.R as CoreUiR +import app.aaps.core.ui.compose.AapsSpacing +import app.aaps.pump.carelevo.R + +internal data class CarelevoAlarmUiModel( + val appIcon: Int, + val title: String, + val content: String, + val primaryButtonText: String, + val muteButtonText: String, + val mute5minButtonText: String +) + +@Composable +internal fun CarelevoAlarmScreen( + alarm: CarelevoAlarmUiModel?, + onPrimaryClick: () -> Unit, + onMuteClick: () -> Unit, + onMute5MinClick: () -> Unit +) { + if (alarm == null) return + + Surface( + modifier = Modifier.fillMaxSize(), + color = Color.Transparent + ) { + Box( + modifier = Modifier + .fillMaxSize() + // Blocking modal scrim: consume all taps so nothing underneath is reachable + // while a critical alarm is presented. + .pointerInput(Unit) { detectTapGestures { } } + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Card( + modifier = Modifier + .padding(horizontal = AapsSpacing.xxLarge) + .fillMaxWidth(), + shape = RoundedCornerShape(AapsSpacing.extraLarge), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier.padding(AapsSpacing.xxLarge), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box(contentAlignment = Alignment.Center) { + Image( + painter = painterResource(id = CoreUiR.drawable.ic_error_red_48dp), + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(AapsSpacing.xxLarge) + .background( + color = MaterialTheme.colorScheme.surface, + shape = CircleShape + ) + .padding(2.dp) + ) { + Image( + painter = painterResource(id = alarm.appIcon), + contentDescription = null, + modifier = Modifier.size(20.dp), + contentScale = ContentScale.Fit + ) + } + } + + Spacer(modifier = Modifier.height(AapsSpacing.extraLarge)) + + Text( + text = alarm.title, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(AapsSpacing.extraLarge)) + + if (alarm.content.isNotBlank()) { + Text( + text = AnnotatedString.fromHtml(alarm.content.replace("\n", "
")), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(modifier = Modifier.height(AapsSpacing.xxLarge)) + + Button( + onClick = onMute5MinClick, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = alarm.mute5minButtonText) + } + + Spacer(modifier = Modifier.height(AapsSpacing.large)) + + Button( + onClick = onMuteClick, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = alarm.muteButtonText) + } + + Spacer(modifier = Modifier.height(AapsSpacing.large)) + + Button( + onClick = onPrimaryClick, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = alarm.primaryButtonText) + } + } + } + } + } +} + +@Preview(showBackground = true, name = "Carelevo Alarm") +@Composable +private fun CarelevoAlarmScreenPreview() { + MaterialTheme { + CarelevoAlarmScreen( + alarm = CarelevoAlarmUiModel( + appIcon = R.drawable.ic_carelevo_128, + title = "Low insulin warning", + content = "Insulin remaining is below threshold.
Please confirm and replace soon.", + primaryButtonText = "Confirm", + muteButtonText = "Mute", + mute5minButtonText = "Mute 5 min" + ), + onPrimaryClick = {}, + onMuteClick = {}, + onMute5MinClick = {} + ) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/dialog/CarelevoActionDialog.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/dialog/CarelevoActionDialog.kt new file mode 100644 index 000000000000..862624779fe7 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/dialog/CarelevoActionDialog.kt @@ -0,0 +1,82 @@ +package app.aaps.pump.carelevo.compose.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +internal fun CarelevoActionDialog( + title: String, + content: String, + onDismissRequest: () -> Unit, + primaryText: String, + onPrimaryClick: () -> Unit, + secondaryText: String? = null, + onSecondaryClick: (() -> Unit)? = null, + subContent: String = "" +) { + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = title) }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (content.isNotBlank()) { + Text( + text = if (content.contains('<') || content.contains("
")) { + AnnotatedString.fromHtml(content.replace("\n", "
")) + } else { + AnnotatedString(content) + }, + style = MaterialTheme.typography.bodyLarge + ) + } + if (subContent.isNotBlank()) { + Text( + text = subContent, + style = MaterialTheme.typography.bodyMedium + ) + } + } + }, + confirmButton = { + TextButton(onClick = onPrimaryClick) { + Text(text = primaryText) + } + }, + dismissButton = { + if (secondaryText != null && onSecondaryClick != null) { + TextButton(onClick = onSecondaryClick) { + Text(text = secondaryText) + } + } + } + ) +} + +@Preview(showBackground = true, name = "Carelevo Action Dialog") +@Composable +private fun CarelevoActionDialogPreview() { + CarelevoActionDialog( + title = "Deactivate patch and start new patch?", + content = "Current patch will be deactivated.", + subContent = "This dialog preview helps verify current text alignment.", + onDismissRequest = {}, + primaryText = "Deactivate", + onPrimaryClick = {}, + secondaryText = "Cancel", + onSecondaryClick = {} + ) +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/dialog/CarelevoPumpStopDurationDialog.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/dialog/CarelevoPumpStopDurationDialog.kt new file mode 100644 index 000000000000..17383d805b3e --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/dialog/CarelevoPumpStopDurationDialog.kt @@ -0,0 +1,60 @@ +package app.aaps.pump.carelevo.compose.dialog + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import app.aaps.pump.carelevo.R + +@Composable +internal fun CarelevoPumpStopDurationDialog( + options: List, + labels: List, + initialIndex: Int, + onDismissRequest: () -> Unit, + onConfirm: (Int) -> Unit +) { + var selectedIndex by remember(initialIndex) { mutableIntStateOf(initialIndex) } + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text(text = stringResource(R.string.carelevo_pump_stop_duration_title)) }, + text = { + Column { + labels.forEachIndexed { index, label -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = selectedIndex == index, + onClick = { selectedIndex = index } + ) + Text(text = label) + } + } + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(options[selectedIndex]) }) { + Text(text = stringResource(R.string.carelevo_btn_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(R.string.carelevo_btn_cancel)) + } + } + ) +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowScreen.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowScreen.kt new file mode 100644 index 000000000000..baac59a5b3eb --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowScreen.kt @@ -0,0 +1,217 @@ +package app.aaps.pump.carelevo.compose.patchflow + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.aaps.core.ui.R as CoreUiR +import app.aaps.core.ui.compose.AapsSpacing +import app.aaps.core.ui.compose.ToolbarConfig +import app.aaps.core.ui.compose.pump.ProfileGateWizardStep +import app.aaps.core.ui.compose.pump.WizardScreen +import app.aaps.core.ui.compose.siteRotation.SiteLocationWizardStep +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.common.model.UiState +import app.aaps.pump.carelevo.presentation.model.CarelevoConnectEvent +import app.aaps.pump.carelevo.presentation.type.CarelevoPatchStep +import app.aaps.pump.carelevo.presentation.type.CarelevoScreenType +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchConnectViewModel +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchConnectionFlowViewModel +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchNeedleInsertionViewModel +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchSafetyCheckViewModel + +@Composable +internal fun CarelevoPatchFlowScreen( + screenType: CarelevoScreenType, + setToolbarConfig: (ToolbarConfig) -> Unit, + snackbarHostState: SnackbarHostState, + onExitFlow: () -> Unit +) { + CarelevoPatchConnectionFlowScreen( + screenType = screenType, + setToolbarConfig = setToolbarConfig, + snackbarHostState = snackbarHostState, + onExitFlow = onExitFlow + ) +} + +@Composable +private fun CarelevoPatchConnectionFlowScreen( + screenType: CarelevoScreenType, + setToolbarConfig: (ToolbarConfig) -> Unit, + snackbarHostState: SnackbarHostState, + onExitFlow: () -> Unit +) { + val viewModel: CarelevoPatchConnectionFlowViewModel = hiltViewModel() + val connectViewModel: CarelevoPatchConnectViewModel = hiltViewModel() + val needleInsertionViewModel: CarelevoPatchNeedleInsertionViewModel = hiltViewModel() + val safetyCheckViewModel: CarelevoPatchSafetyCheckViewModel = hiltViewModel() + val page by viewModel.page.collectAsStateWithLifecycle() + val totalSteps by viewModel.totalSteps.collectAsStateWithLifecycle() + val currentStepIndex by viewModel.currentStepIndex.collectAsStateWithLifecycle() + val sharedUiState by viewModel.uiState.collectAsStateWithLifecycle() + val connectUiState by connectViewModel.uiState.collectAsStateWithLifecycle() + val needleInsertionUiState by needleInsertionViewModel.uiState.collectAsStateWithLifecycle() + val safetyCheckUiState by safetyCheckViewModel.uiState.collectAsStateWithLifecycle() + val discardFailedMessage = stringResource(R.string.carelevo_toast_msg_discard_failed) + + // Every exit path MUST drop the ViewModel's one-shot init latch: the VM outlives this screen + // (it is scoped to the plugin's NavBackStackEntry), so without the reset a SECOND activation + // started from the same plugin visit would reuse the previous run's workflowSteps and page — + // i.e. the wizard could resume at NEEDLE_INSERTION against a brand-new, never-safety-checked + // patch. The latch itself stays (it is what preserves mid-flow progress across rotation). + val exitFlow: () -> Unit = remember(viewModel, onExitFlow) { + { + viewModel.setIsCreated(false) + onExitFlow() + } + } + + LaunchedEffect(viewModel) { + if (!viewModel.isCreated) { + viewModel.initWorkflow(screenType) + viewModel.setIsCreated(true) + } + } + + LaunchedEffect(page) { + if (page == CarelevoPatchStep.PATCH_CONNECT) { + connectViewModel.resetForEnterStep() + } + } + + LaunchedEffect(viewModel) { + viewModel.event.collect { event -> + when (event) { + CarelevoConnectEvent.DiscardComplete -> exitFlow() + + CarelevoConnectEvent.DiscardFailed -> { + snackbarHostState.showSnackbar(discardFailedMessage) + } + + CarelevoConnectEvent.ExitFlow -> exitFlow() + + else -> Unit + } + } + } + + Box( + modifier = Modifier + .fillMaxSize() + ) { + WizardScreen( + currentStep = page, + totalSteps = totalSteps, + currentStepIndex = currentStepIndex, + canGoBack = true, + onBack = { viewModel.startPatchDiscardProcess() }, + cancelDialogTitle = stringResource(R.string.carelevo_dialog_patch_discard_message_title), + cancelDialogText = stringResource(R.string.carelevo_dialog_patch_discard_message_desc), + title = patchStepTitle(page), + setToolbarConfig = setToolbarConfig, + ) { step, _ -> + when (step) { + CarelevoPatchStep.PROFILE_GATE -> { + ProfileGateWizardStep(host = viewModel) + } + + CarelevoPatchStep.SELECT_INSULIN -> { + CarelevoSelectInsulinStep(viewModel = viewModel) + } + + CarelevoPatchStep.PATCH_START -> { + CarelevoPatchFlowStep01Start( + viewModel = viewModel, + onExitFlow = exitFlow + ) + } + + CarelevoPatchStep.SET_AMOUNT -> { + CarelevoSetAmountStep(viewModel = viewModel) + } + + CarelevoPatchStep.PATCH_CONNECT -> { + CarelevoPatchFlowStep02Connect( + viewModel = connectViewModel, + sharedViewModel = viewModel, + onExitFlow = exitFlow + ) + } + + CarelevoPatchStep.SAFETY_CHECK -> { + CarelevoPatchFlowStep03SafetyCheck( + viewModel = safetyCheckViewModel, + sharedViewModel = viewModel, + onExitFlow = exitFlow + ) + } + + CarelevoPatchStep.SITE_LOCATION -> { + SiteLocationWizardStep(host = viewModel) + } + + CarelevoPatchStep.PATCH_ATTACH -> { + CarelevoPatchFlowStep04Attach( + viewModel = viewModel + ) + } + + CarelevoPatchStep.NEEDLE_INSERTION -> { + CarelevoPatchFlowStep05NeedleInsertion( + viewModel = needleInsertionViewModel, + onExitFlow = exitFlow + ) + } + } + } + + val activeUiState = when (page) { + CarelevoPatchStep.PATCH_CONNECT -> connectUiState + CarelevoPatchStep.NEEDLE_INSERTION -> needleInsertionUiState + CarelevoPatchStep.SAFETY_CHECK -> safetyCheckUiState + else -> sharedUiState + } + + if (activeUiState is UiState.Loading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)) + // Consume all taps: without this the scrim is decoration only and a + // double-tap can still reach (and re-fire) the buttons underneath while + // the first BLE command is in flight. + .pointerInput(Unit) { detectTapGestures { } }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AapsSpacing.extraLarge) + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + Text( + text = stringResource(CoreUiR.string.loading), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep01Start.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep01Start.kt new file mode 100644 index 000000000000..c9944cd89642 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep01Start.kt @@ -0,0 +1,115 @@ +package app.aaps.pump.carelevo.compose.patchflow + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import app.aaps.core.ui.R as CoreUiR +import app.aaps.core.ui.compose.AapsSpacing +import app.aaps.core.ui.compose.pump.WizardButton +import app.aaps.core.ui.compose.pump.WizardStepLayout +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.config.FillConfig +import app.aaps.pump.carelevo.presentation.type.CarelevoPatchStep +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchConnectionFlowViewModel + +@Composable +internal fun CarelevoPatchFlowStep01Start( + viewModel: CarelevoPatchConnectionFlowViewModel, + onExitFlow: () -> Unit +) { + CarelevoPatchStartContent( + onNextClick = { viewModel.setPage(CarelevoPatchStep.SET_AMOUNT) }, + onCancelClick = onExitFlow + ) +} + +@Composable +private fun CarelevoPatchStartContent( + onNextClick: () -> Unit, + onCancelClick: () -> Unit +) { + var guideExpanded by remember { mutableStateOf(false) } + WizardStepLayout( + primaryButton = WizardButton( + text = stringResource(CoreUiR.string.next), + onClick = onNextClick + ), + secondaryButton = WizardButton( + text = stringResource(CoreUiR.string.cancel), + onClick = onCancelClick + ) + ) { + Text( + text = stringResource(R.string.carelevo_title_fill_insulin), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(R.string.carelevo_notice_fill_insulin_amount, FillConfig.FILL_MIN_UNITS, FillConfig.FILL_MAX_UNITS), + style = MaterialTheme.typography.bodyMedium + ) + CarelevoInsulinRefillGuideSection( + expanded = guideExpanded, + onExpand = { guideExpanded = true } + ) + } +} + +@Composable +private fun CarelevoInsulinRefillGuideSection( + expanded: Boolean, + onExpand: () -> Unit +) { + if (!expanded) { + Button(onClick = onExpand) { + Text(text = stringResource(R.string.carelevo_btn_insulin_guide)) + } + return + } + + val steps = listOf( + stringResource(R.string.carelevo_insulin_refill_step1), + stringResource(R.string.carelevo_insulin_refill_step2), + stringResource(R.string.carelevo_insulin_refill_step3), + stringResource(R.string.carelevo_insulin_refill_step4), + stringResource(R.string.carelevo_insulin_refill_step5), + stringResource(R.string.carelevo_insulin_refill_step6), + stringResource(R.string.carelevo_insulin_refill_step7), + stringResource(R.string.carelevo_insulin_refill_step8), + stringResource(R.string.carelevo_insulin_refill_step9), + stringResource(R.string.carelevo_insulin_refill_step10), + stringResource(R.string.carelevo_insulin_refill_step11), + stringResource(R.string.carelevo_insulin_refill_step12) + ) + Column(verticalArrangement = Arrangement.spacedBy(AapsSpacing.large)) { + Text( + text = stringResource(R.string.carelevo_btn_insulin_guide), + style = MaterialTheme.typography.titleSmall + ) + steps.forEach { step -> + Text( + text = step, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + +@Preview(showBackground = true, name = "Patch Start") +@Composable +private fun CarelevoPatchFlowStep01StartPreview() { + MaterialTheme { + CarelevoPatchStartContent( + onNextClick = {}, + onCancelClick = {} + ) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep02Connect.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep02Connect.kt new file mode 100644 index 000000000000..43e15e1a4cdf --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep02Connect.kt @@ -0,0 +1,197 @@ +package app.aaps.pump.carelevo.compose.patchflow + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import app.aaps.core.ui.compose.AapsSpacing +import app.aaps.core.ui.compose.banner.ErrorBanner +import app.aaps.core.ui.compose.pump.WizardButton +import app.aaps.core.ui.compose.pump.WizardStepLayout +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.compose.dialog.CarelevoActionDialog +import app.aaps.pump.carelevo.presentation.model.CarelevoConnectPrepareEvent +import app.aaps.pump.carelevo.presentation.type.CarelevoPatchStep +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchConnectViewModel +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchConnectionFlowViewModel + +@Composable +internal fun CarelevoPatchFlowStep02Connect( + viewModel: CarelevoPatchConnectViewModel, + sharedViewModel: CarelevoPatchConnectionFlowViewModel, + onExitFlow: () -> Unit +) { + var showDiscardDialog by remember { mutableStateOf(false) } + var showConnectDialog by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + LaunchedEffect(viewModel, sharedViewModel) { + viewModel.event.collect { event -> + when (event) { + CarelevoConnectPrepareEvent.ShowConnectDialog -> { + showConnectDialog = true + } + + CarelevoConnectPrepareEvent.ShowMessageScanFailed -> { + errorMessage = R.string.carelevo_toast_msg_scan_failed + } + + CarelevoConnectPrepareEvent.ShowMessageBluetoothNotEnabled -> { + errorMessage = R.string.carelevo_toast_msg_bluetooth_not_enabled + } + + CarelevoConnectPrepareEvent.ShowMessageScanIsWorking -> Unit + + CarelevoConnectPrepareEvent.ShowMessageNotSetUserSettingInfo -> { + errorMessage = R.string.carelevo_toast_msg_profile_not_set + } + + CarelevoConnectPrepareEvent.ShowMessageSelectedDeviceIseEmpty -> { + errorMessage = R.string.carelevo_toast_msg_patch_not_found + } + + CarelevoConnectPrepareEvent.ConnectFailed -> { + showConnectDialog = false + errorMessage = R.string.carelevo_toast_msg_connect_failed + } + + CarelevoConnectPrepareEvent.ConnectComplete -> { + showConnectDialog = false + errorMessage = null + sharedViewModel.setPage(CarelevoPatchStep.SAFETY_CHECK) + } + + CarelevoConnectPrepareEvent.DiscardComplete -> { + showDiscardDialog = false + onExitFlow() + } + + CarelevoConnectPrepareEvent.DiscardFailed -> { + showDiscardDialog = false + errorMessage = R.string.carelevo_toast_msg_discard_failed + } + + CarelevoConnectPrepareEvent.NoAction -> Unit + } + } + } + + if (showDiscardDialog) { + CarelevoActionDialog( + onDismissRequest = { showDiscardDialog = false }, + title = stringResource(R.string.carelevo_dialog_patch_discard_message_title), + content = stringResource(R.string.carelevo_dialog_patch_discard_message_desc), + primaryText = stringResource(R.string.carelevo_btn_confirm), + onPrimaryClick = { + showDiscardDialog = false + viewModel.startPatchDiscardProcess() + }, + secondaryText = stringResource(R.string.carelevo_btn_cancel), + onSecondaryClick = { showDiscardDialog = false } + ) + } + + if (showConnectDialog) { + CarelevoActionDialog( + onDismissRequest = { showConnectDialog = false }, + title = stringResource(R.string.carelevo_dialog_patch_connect_message_title), + content = "CareLevo", + primaryText = stringResource(R.string.carelevo_btn_confirm), + onPrimaryClick = { + showConnectDialog = false + viewModel.startConnect(sharedViewModel.inputInsulin) + }, + secondaryText = stringResource(R.string.carelevo_btn_research), + onSecondaryClick = { + showConnectDialog = false + viewModel.startScan() + } + ) + } + + CarelevoPatchConnectContent( + errorMessage = errorMessage, + onDiscardClick = { showDiscardDialog = true }, + onSearchClick = { + errorMessage = null + viewModel.startScan() + } + ) +} + +@Composable +private fun CarelevoPatchConnectContent( + errorMessage: Int?, + onDiscardClick: () -> Unit, + onSearchClick: () -> Unit +) { + WizardStepLayout( + primaryButton = WizardButton( + text = stringResource(R.string.carelevo_btn_input_search_patch), + onClick = onSearchClick + ), + secondaryButton = WizardButton( + text = stringResource(R.string.carelevo_btn_patch_expiration), + onClick = onDiscardClick + ) + ) { + errorMessage?.let { ErrorBanner(message = stringResource(it)) } + CarelevoPatchConnectStepSection( + stepLabel = stringResource(R.string.carelevo_patch_step_1), + title = stringResource(R.string.carelevo_patch_connect_step_1_title), + description = stringResource(R.string.carelevo_patch_connect_step_1_desc) + ) + CarelevoPatchConnectStepSection( + stepLabel = stringResource(R.string.carelevo_patch_step_2), + title = stringResource(R.string.carelevo_patch_connect_step_2_title), + description = stringResource(R.string.carelevo_patch_connect_step_2_desc) + ) + } +} + +@Composable +private fun CarelevoPatchConnectStepSection( + stepLabel: String, + title: String, + description: String +) { + Column(verticalArrangement = Arrangement.spacedBy(AapsSpacing.medium)) { + Row(horizontalArrangement = Arrangement.spacedBy(AapsSpacing.small), verticalAlignment = Alignment.Bottom) { + Text( + text = stepLabel, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + } + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Preview(showBackground = true, name = "Patch Connect") +@Composable +private fun CarelevoPatchFlowStep02ConnectPreview() { + MaterialTheme { + CarelevoPatchConnectContent( + errorMessage = null, + onDiscardClick = {}, + onSearchClick = {} + ) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep03SafetyCheck.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep03SafetyCheck.kt new file mode 100644 index 000000000000..a07676941d05 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep03SafetyCheck.kt @@ -0,0 +1,324 @@ +package app.aaps.pump.carelevo.compose.patchflow + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.aaps.core.ui.compose.AapsSpacing +import app.aaps.core.ui.compose.banner.ErrorBanner +import app.aaps.core.ui.compose.pump.WizardButton +import app.aaps.core.ui.compose.pump.WizardStepLayout +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.compose.dialog.CarelevoActionDialog +import app.aaps.pump.carelevo.presentation.model.CarelevoConnectSafetyCheckEvent +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchConnectionFlowViewModel +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchSafetyCheckViewModel + +@Composable +internal fun CarelevoPatchFlowStep03SafetyCheck( + viewModel: CarelevoPatchSafetyCheckViewModel, + sharedViewModel: CarelevoPatchConnectionFlowViewModel, + onExitFlow: () -> Unit +) { + val progress by viewModel.progress.collectAsStateWithLifecycle() + val remainSec by viewModel.remainSec.collectAsStateWithLifecycle() + var showDiscardDialog by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var safetyCheckState by remember(viewModel) { + mutableStateOf( + if (viewModel.isSafetyCheckPassed()) { + SafetyCheckUiState.Success + } else { + SafetyCheckUiState.Ready + } + ) + } + + LaunchedEffect(viewModel) { + if (!viewModel.isCreated) { + viewModel.setIsCreated(true) + } + if (viewModel.isSafetyCheckPassed()) { + viewModel.onSafetyCheckComplete() + } + } + + LaunchedEffect(viewModel) { + viewModel.event.collect { event -> + when (event) { + CarelevoConnectSafetyCheckEvent.ShowMessageBluetoothNotEnabled -> { + errorMessage = R.string.carelevo_toast_msg_bluetooth_not_enabled + } + + CarelevoConnectSafetyCheckEvent.ShowMessageCarelevoIsNotConnected -> { + errorMessage = R.string.carelevo_toast_msg_not_connected_waiting_retry + } + + CarelevoConnectSafetyCheckEvent.SafetyCheckProgress -> { + errorMessage = null + safetyCheckState = SafetyCheckUiState.Progress + } + + CarelevoConnectSafetyCheckEvent.SafetyCheckComplete -> { + errorMessage = null + safetyCheckState = SafetyCheckUiState.Success + } + + CarelevoConnectSafetyCheckEvent.SafetyCheckFailed -> { + errorMessage = R.string.carelevo_toast_msg_safety_check_failed + safetyCheckState = SafetyCheckUiState.Ready + } + + CarelevoConnectSafetyCheckEvent.DiscardComplete -> { + showDiscardDialog = false + onExitFlow() + } + + CarelevoConnectSafetyCheckEvent.DiscardFailed -> { + showDiscardDialog = false + errorMessage = R.string.carelevo_toast_msg_discard_failed + } + + CarelevoConnectSafetyCheckEvent.NoAction -> Unit + } + } + } + + if (showDiscardDialog) { + CarelevoActionDialog( + onDismissRequest = { showDiscardDialog = false }, + title = stringResource(R.string.carelevo_dialog_patch_discard_message_title), + content = stringResource(R.string.carelevo_dialog_patch_discard_message_desc), + primaryText = stringResource(R.string.carelevo_btn_confirm), + onPrimaryClick = { + showDiscardDialog = false + viewModel.startDiscardProcess() + }, + secondaryText = stringResource(R.string.carelevo_btn_cancel), + onSecondaryClick = { showDiscardDialog = false } + ) + } + + val titleRes = when (safetyCheckState) { + SafetyCheckUiState.Ready, + SafetyCheckUiState.Progress -> R.string.carelevo_patch_safety_check_start_title + + SafetyCheckUiState.Success -> R.string.carelevo_patch_safety_check_end_title + } + val descRes = when (safetyCheckState) { + SafetyCheckUiState.Ready -> R.string.carelevo_patch_safety_check_start_desc + SafetyCheckUiState.Progress -> R.string.carelevo_patch_safety_check_progress_desc + SafetyCheckUiState.Success -> R.string.carelevo_patch_safety_check_end_desc + } + + val nextEnabled = safetyCheckState == SafetyCheckUiState.Success + val showSafetyCheckButton = safetyCheckState == SafetyCheckUiState.Ready + val showRetrySection = safetyCheckState == SafetyCheckUiState.Success + val showProgressDetails = safetyCheckState != SafetyCheckUiState.Ready + // While the ~100-210 s check is streaming, block the discard escape hatch: tapping it would + // enqueue a CmdDiscard behind the still-running CmdSafetyCheck — a confusing interleaving + // that never reflects what the user sees on screen. (The global Loading overlay is deliberately + // NOT used here — it would hide this step's live progress display.) + val discardEnabled = safetyCheckState != SafetyCheckUiState.Progress + + CarelevoPatchFlowStep03SafetyCheckContent( + titleRes = titleRes, + descRes = descRes, + errorMessage = errorMessage, + progress = progress, + remainSec = remainSec, + showProgressDetails = showProgressDetails, + showRetrySection = showRetrySection, + showSafetyCheckButton = showSafetyCheckButton, + nextEnabled = nextEnabled, + discardEnabled = discardEnabled, + onRetryClick = { + errorMessage = null + viewModel.retryAdditionalPriming() + }, + onDiscardClick = { showDiscardDialog = true }, + onSafetyCheckClick = { + errorMessage = null + viewModel.startSafetyCheck() + }, + onNextClick = { sharedViewModel.advanceFromSafetyCheck() } + ) +} + +@Composable +private fun CarelevoPatchFlowStep03SafetyCheckContent( + titleRes: Int, + descRes: Int, + errorMessage: Int?, + progress: Int?, + remainSec: Long?, + showProgressDetails: Boolean, + showRetrySection: Boolean, + showSafetyCheckButton: Boolean, + nextEnabled: Boolean, + discardEnabled: Boolean = true, + onRetryClick: () -> Unit, + onDiscardClick: () -> Unit, + onSafetyCheckClick: () -> Unit, + onNextClick: () -> Unit +) { + WizardStepLayout( + primaryButton = if (showSafetyCheckButton) { + WizardButton( + text = stringResource(R.string.carelevo_btn_safety_check), + onClick = onSafetyCheckClick + ) + } else { + WizardButton( + text = stringResource(R.string.carelevo_btn_next), + onClick = onNextClick, + enabled = nextEnabled + ) + }, + secondaryButton = WizardButton( + text = stringResource(R.string.carelevo_btn_patch_expiration), + onClick = onDiscardClick, + enabled = discardEnabled + ) + ) { + errorMessage?.let { ErrorBanner(message = stringResource(it)) } + Text( + text = stringResource(titleRes), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(descRes), + style = MaterialTheme.typography.bodyMedium + ) + LinearProgressIndicator( + progress = { if (showProgressDetails) (progress ?: 0) / 100f else 0f }, + modifier = Modifier + .fillMaxWidth() + .height(10.dp) + ) + if (showProgressDetails && (remainSec != null || progress != null)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = remainTimeText(remainSec), + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = progressText(progress), + style = MaterialTheme.typography.bodyMedium + ) + } + } + if (showRetrySection) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(AapsSpacing.small) + ) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.carelevo_patch_safety_check_desc_warning), + style = MaterialTheme.typography.bodyMedium + ) + } + Text( + text = stringResource(R.string.carelevo_patch_safety_check_retry_desc), + style = MaterialTheme.typography.bodyMedium + ) + Button( + onClick = onRetryClick, + modifier = Modifier.wrapContentWidth() + ) { + Text(text = stringResource(R.string.carelevo_btn_retry)) + } + } + } +} + +private enum class SafetyCheckUiState { + Ready, + Progress, + Success +} + +@Composable +private fun remainTimeText(remainSeconds: Long?): String { + if (remainSeconds == null) return "" + val minutes = remainSeconds / 60 + val seconds = remainSeconds % 60 + return stringResource(R.string.common_unit_remain_min_sec, minutes, seconds) +} + +@Composable +private fun progressText(progress: Int?): String = + if (progress == null) "" else stringResource(R.string.carelevo_progress_of_100, progress) + +@Preview(showBackground = true, name = "Safety Check Ready") +@Composable +private fun CarelevoPatchFlowStep03SafetyCheckReadyPreview() { + MaterialTheme { + CarelevoPatchFlowStep03SafetyCheckContent( + titleRes = R.string.carelevo_patch_safety_check_start_title, + descRes = R.string.carelevo_patch_safety_check_start_desc, + errorMessage = null, + progress = 0, + remainSec = 180, + showProgressDetails = false, + showRetrySection = false, + showSafetyCheckButton = true, + nextEnabled = false, + onRetryClick = {}, + onDiscardClick = {}, + onSafetyCheckClick = {}, + onNextClick = {} + ) + } +} + +@Preview(showBackground = true, name = "Safety Check Success") +@Composable +private fun CarelevoPatchFlowStep03SafetyCheckSuccessPreview() { + MaterialTheme { + CarelevoPatchFlowStep03SafetyCheckContent( + titleRes = R.string.carelevo_patch_safety_check_end_title, + descRes = R.string.carelevo_patch_safety_check_end_desc, + errorMessage = null, + progress = 100, + remainSec = 0, + showProgressDetails = true, + showRetrySection = true, + showSafetyCheckButton = false, + nextEnabled = true, + onRetryClick = {}, + onDiscardClick = {}, + onSafetyCheckClick = {}, + onNextClick = {} + ) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep04Attach.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep04Attach.kt new file mode 100644 index 000000000000..579e62156003 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep04Attach.kt @@ -0,0 +1,94 @@ +package app.aaps.pump.carelevo.compose.patchflow + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import app.aaps.core.ui.R as CoreUiR +import app.aaps.core.ui.compose.AapsSpacing +import app.aaps.core.ui.compose.pump.WizardButton +import app.aaps.core.ui.compose.pump.WizardStepLayout +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.presentation.type.CarelevoPatchStep +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchConnectionFlowViewModel + +@Composable +internal fun CarelevoPatchFlowStep04Attach( + viewModel: CarelevoPatchConnectionFlowViewModel +) { + CarelevoPatchFlowStep04AttachContent( + onReadyClick = { viewModel.setPage(CarelevoPatchStep.NEEDLE_INSERTION) } + ) +} + +@Composable +private fun CarelevoPatchFlowStep04AttachContent( + onReadyClick: () -> Unit +) { + WizardStepLayout( + primaryButton = WizardButton( + text = stringResource(CoreUiR.string.next), + onClick = onReadyClick + ) + ) { + CarelevoPatchAttachSection( + stepLabel = stringResource(R.string.carelevo_patch_step_1), + title = stringResource(R.string.carelevo_patch_attach_step1_title), + description = stringResource(R.string.carelevo_patch_attach_step1_desc) + ) + CarelevoPatchAttachSection( + stepLabel = stringResource(R.string.carelevo_patch_step_2), + title = stringResource(R.string.carelevo_patch_attach_step2_title), + description = stringResource(R.string.carelevo_patch_attach_step2_desc) + ) + CarelevoPatchAttachSection( + stepLabel = stringResource(R.string.carelevo_patch_step_3), + title = stringResource(R.string.carelevo_patch_attach_step3_title), + description = stringResource(R.string.carelevo_patch_attach_step3_desc) + ) + Text( + text = stringResource(R.string.carelevo_patch_attach_step4_desc), + style = MaterialTheme.typography.bodyLarge + ) + } +} + +@Composable +private fun CarelevoPatchAttachSection( + stepLabel: String, + title: String, + description: String +) { + Column(verticalArrangement = Arrangement.spacedBy(AapsSpacing.medium)) { + Row(horizontalArrangement = Arrangement.spacedBy(AapsSpacing.small), verticalAlignment = Alignment.Bottom) { + Text( + text = stepLabel, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + } + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Preview(showBackground = true, name = "Patch Attach") +@Composable +private fun CarelevoPatchFlowStep04AttachPreview() { + MaterialTheme { + CarelevoPatchFlowStep04AttachContent( + onReadyClick = {} + ) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep05NeedleInsertion.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep05NeedleInsertion.kt new file mode 100644 index 000000000000..9f262cf1099a --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep05NeedleInsertion.kt @@ -0,0 +1,273 @@ +package app.aaps.pump.carelevo.compose.patchflow + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.aaps.core.ui.compose.AapsSpacing +import app.aaps.core.ui.compose.banner.ErrorBanner +import app.aaps.core.ui.compose.pump.WizardButton +import app.aaps.core.ui.compose.pump.WizardStepLayout +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.compose.dialog.CarelevoActionDialog +import app.aaps.pump.carelevo.presentation.model.CarelevoConnectNeedleEvent +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchNeedleInsertionViewModel + +private const val MAX_NEEDLE_CHECK_COUNT = 3 + +@Composable +internal fun CarelevoPatchFlowStep05NeedleInsertion( + viewModel: CarelevoPatchNeedleInsertionViewModel, + onExitFlow: () -> Unit +) { + val isNeedleInserted by viewModel.isNeedleInsert.collectAsStateWithLifecycle() + var showDiscardDialog by remember { mutableStateOf(false) } + var failCount by remember { mutableIntStateOf(0) } + var errorMessage by remember { mutableStateOf(null) } + + LaunchedEffect(viewModel) { + if (!viewModel.isCreated) { + viewModel.observePatchInfo() + viewModel.setIsCreated(true) + } + } + + LaunchedEffect(viewModel) { + viewModel.event.collect { event -> + when (event) { + CarelevoConnectNeedleEvent.ShowMessageBluetoothNotEnabled -> { + errorMessage = R.string.carelevo_toast_msg_bluetooth_not_enabled + } + + CarelevoConnectNeedleEvent.ShowMessageCarelevoIsNotConnected -> { + errorMessage = R.string.carelevo_toast_msg_not_connected_waiting_retry + } + + CarelevoConnectNeedleEvent.ShowMessageProfileNotSet -> { + errorMessage = R.string.carelevo_toast_msg_profile_not_set + } + + is CarelevoConnectNeedleEvent.CheckNeedleComplete -> { + errorMessage = null + } + + is CarelevoConnectNeedleEvent.CheckNeedleFailed -> { + failCount = event.failedCount + if (event.failedCount >= MAX_NEEDLE_CHECK_COUNT) { + onExitFlow() + } + } + + CarelevoConnectNeedleEvent.CheckNeedleError -> { + errorMessage = R.string.carelevo_toast_msg_needle_check_failed + } + + CarelevoConnectNeedleEvent.DiscardComplete -> { + showDiscardDialog = false + onExitFlow() + } + + CarelevoConnectNeedleEvent.DiscardFailed -> { + showDiscardDialog = false + errorMessage = R.string.carelevo_toast_msg_discard_failed + } + + CarelevoConnectNeedleEvent.SetBasalComplete -> { + onExitFlow() + } + + CarelevoConnectNeedleEvent.SetBasalFailed -> { + errorMessage = R.string.carelevo_toast_msg_set_basal_failed + } + + CarelevoConnectNeedleEvent.NoAction -> Unit + } + } + } + + if (showDiscardDialog) { + CarelevoActionDialog( + onDismissRequest = { showDiscardDialog = false }, + title = stringResource(R.string.carelevo_dialog_patch_discard_message_title), + content = stringResource(R.string.carelevo_dialog_patch_discard_message_desc), + primaryText = stringResource(R.string.carelevo_btn_confirm), + onPrimaryClick = { + showDiscardDialog = false + viewModel.startDiscardProcess() + }, + secondaryText = stringResource(R.string.carelevo_btn_cancel), + onSecondaryClick = { showDiscardDialog = false } + ) + } + + CarelevoPatchFlowStep05NeedleInsertionContent( + isNeedleInserted = isNeedleInserted, + failCount = failCount, + errorMessage = errorMessage, + onCheckClick = { + errorMessage = null + viewModel.startCheckNeedle() + }, + onStartDeliveryClick = { + errorMessage = null + viewModel.startSetBasal() + }, + onDiscardClick = { showDiscardDialog = true } + ) +} + +@Composable +private fun CarelevoPatchFlowStep05NeedleInsertionContent( + isNeedleInserted: Boolean, + failCount: Int, + errorMessage: Int?, + onCheckClick: () -> Unit, + onStartDeliveryClick: () -> Unit, + onDiscardClick: () -> Unit +) { + val deactivateButton = WizardButton( + text = stringResource(R.string.carelevo_btn_patch_expiration), + onClick = onDiscardClick + ) + + if (isNeedleInserted) { + // Needle detected — guide the user to detach the applicator, then start delivery. + WizardStepLayout( + primaryButton = WizardButton( + text = stringResource(R.string.carelevo_dialog_connect_detached), + onClick = onStartDeliveryClick + ), + secondaryButton = deactivateButton + ) { + errorMessage?.let { ErrorBanner(message = stringResource(it)) } + Text( + text = stringResource(R.string.carelevo_dialog_patch_connect_needle_injected), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = AnnotatedString.fromHtml( + stringResource(R.string.carelevo_dialog_connect_detach_applicator_guide).replace("\n", "
") + ), + style = MaterialTheme.typography.bodyMedium + ) + } + return + } + + // Needle not detected yet — show insertion instructions and let the user run the check. + val isRetry = failCount > 0 + WizardStepLayout( + primaryButton = WizardButton( + text = if (isRetry) { + stringResource(R.string.carelevo_btn_retry) + } else { + stringResource(R.string.carelevo_btn_needle_insert_check) + }, + onClick = onCheckClick + ), + secondaryButton = deactivateButton + ) { + errorMessage?.let { ErrorBanner(message = stringResource(it)) } + CarelevoPatchNeedleSection( + stepLabel = stringResource(R.string.carelevo_patch_step_1), + title = stringResource(R.string.carelevo_patch_needle_insertion_step1_title), + description = stringResource(R.string.carelevo_patch_needle_insertion_step1_desc) + ) + CarelevoPatchNeedleSection( + stepLabel = stringResource(R.string.carelevo_patch_step_2), + title = stringResource(R.string.carelevo_patch_needle_insertion_step2_title), + description = stringResource(R.string.carelevo_patch_needle_insertion_step2_desc) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(AapsSpacing.medium) + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary + ) + Text( + text = stringResource(R.string.carelevo_patch_needle_insertion_desc_warning), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.tertiary + ) + } + + if (isRetry) { + Text( + text = stringResource( + R.string.carelevo_dialog_patch_needle_retry_count, + (MAX_NEEDLE_CHECK_COUNT - failCount).coerceAtLeast(0) + ), + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + +@Composable +private fun CarelevoPatchNeedleSection( + stepLabel: String, + title: String, + description: String +) { + Column(verticalArrangement = Arrangement.spacedBy(AapsSpacing.medium)) { + Row(horizontalArrangement = Arrangement.spacedBy(AapsSpacing.small), verticalAlignment = Alignment.Bottom) { + Text(text = stepLabel, style = MaterialTheme.typography.titleMedium) + Text(text = title, style = MaterialTheme.typography.titleMedium) + } + Text(text = description, style = MaterialTheme.typography.bodyMedium) + } +} + +@Preview(showBackground = true, name = "Needle Insertion - not detected") +@Composable +private fun CarelevoPatchFlowStep05NotDetectedPreview() { + MaterialTheme { + CarelevoPatchFlowStep05NeedleInsertionContent( + isNeedleInserted = false, + failCount = 0, + errorMessage = null, + onCheckClick = {}, + onStartDeliveryClick = {}, + onDiscardClick = {} + ) + } +} + +@Preview(showBackground = true, name = "Needle Insertion - detected") +@Composable +private fun CarelevoPatchFlowStep05DetectedPreview() { + MaterialTheme { + CarelevoPatchFlowStep05NeedleInsertionContent( + isNeedleInserted = true, + failCount = 0, + errorMessage = null, + onCheckClick = {}, + onStartDeliveryClick = {}, + onDiscardClick = {} + ) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowSupport.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowSupport.kt new file mode 100644 index 000000000000..b044b4db8f65 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowSupport.kt @@ -0,0 +1,21 @@ +package app.aaps.pump.carelevo.compose.patchflow + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import app.aaps.core.ui.R as CoreUiR +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.presentation.type.CarelevoPatchStep + +@Composable +internal fun patchStepTitle(step: CarelevoPatchStep): String = + when (step) { + CarelevoPatchStep.PROFILE_GATE -> stringResource(CoreUiR.string.pump_wizard_profile_gate_title) + CarelevoPatchStep.SELECT_INSULIN -> stringResource(CoreUiR.string.select_insulin) + CarelevoPatchStep.PATCH_START -> stringResource(R.string.carelevo_connect_prepare_title) + CarelevoPatchStep.SET_AMOUNT -> stringResource(R.string.patch_prepare_dialog_title_insulin_amount) + CarelevoPatchStep.PATCH_CONNECT -> stringResource(R.string.carelevo_connect_patch_title) + CarelevoPatchStep.SAFETY_CHECK -> stringResource(R.string.carelevo_connect_safety_check_title) + CarelevoPatchStep.SITE_LOCATION -> stringResource(CoreUiR.string.site_rotation) + CarelevoPatchStep.PATCH_ATTACH -> stringResource(R.string.carelevo_connect_patch_attach_title) + CarelevoPatchStep.NEEDLE_INSERTION -> stringResource(R.string.carelevo_connect_needle_check_title) + } diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoSelectInsulinStep.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoSelectInsulinStep.kt new file mode 100644 index 000000000000..1755e8738831 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoSelectInsulinStep.kt @@ -0,0 +1,46 @@ +package app.aaps.pump.carelevo.compose.patchflow + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.aaps.core.ui.R as CoreUiR +import app.aaps.core.ui.compose.insulin.SelectInsulin +import app.aaps.core.ui.compose.pump.WizardButton +import app.aaps.core.ui.compose.pump.WizardStepLayout +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchConnectionFlowViewModel + +/** + * Wizard step letting the user pick which insulin the pump/loop should use. Shown only when more + * than one insulin is configured (`showInsulinStep`). Mirrors the Medtrum/Equil insulin step and + * reuses the shared [SelectInsulin] picker. + */ +@Composable +internal fun CarelevoSelectInsulinStep( + viewModel: CarelevoPatchConnectionFlowViewModel +) { + val availableInsulins by viewModel.availableInsulins.collectAsStateWithLifecycle() + val selectedInsulin by viewModel.selectedInsulin.collectAsStateWithLifecycle() + val activeInsulinLabel by viewModel.activeInsulinLabel.collectAsStateWithLifecycle() + + WizardStepLayout( + primaryButton = WizardButton( + text = stringResource(CoreUiR.string.next), + onClick = { viewModel.advanceFromInsulin() }, + enabled = selectedInsulin != null + ), + secondaryButton = WizardButton( + text = stringResource(CoreUiR.string.cancel), + onClick = { viewModel.exitWizard() } + ) + ) { + SelectInsulin( + availableInsulins = availableInsulins, + selectedInsulin = selectedInsulin, + activeInsulinLabel = activeInsulinLabel, + onInsulinSelect = { viewModel.selectInsulin(it) }, + initialExpanded = true, + concentrationDropDownEnabled = viewModel.concentrationEnabled + ) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoSetAmountStep.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoSetAmountStep.kt new file mode 100644 index 000000000000..d656f4187673 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoSetAmountStep.kt @@ -0,0 +1,209 @@ +package app.aaps.pump.carelevo.compose.patchflow + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.aaps.core.ui.R as CoreUiR +import app.aaps.core.ui.compose.pump.WizardButton +import app.aaps.core.ui.compose.pump.WizardStepLayout +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.config.FillConfig +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchConnectionFlowViewModel +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlin.math.abs + +/** + * Wizard step for choosing the insulin fill amount. Replaces the former bottom sheet so the choice + * stays on the wizard rails (progress indicator, Next/Cancel). The wheel picker is non-scrolling + * content, so [WizardStepLayout] is used with `scrollable = false`. + */ +@Composable +internal fun CarelevoSetAmountStep( + viewModel: CarelevoPatchConnectionFlowViewModel +) { + var selectedValue by remember { mutableIntStateOf(viewModel.inputInsulin) } + val values = remember { (FillConfig.FILL_MIN_UNITS..FillConfig.FILL_MAX_UNITS step FillConfig.FILL_STEP_UNITS).toList() } + + WizardStepLayout( + primaryButton = WizardButton( + text = stringResource(CoreUiR.string.next), + onClick = { viewModel.confirmAmount(selectedValue) } + ), + secondaryButton = WizardButton( + text = stringResource(CoreUiR.string.cancel), + onClick = { viewModel.exitWizard() } + ), + scrollable = false + ) { + Text( + text = stringResource(R.string.patch_prepare_dialog_msg_insulin_range, FillConfig.FILL_MIN_UNITS, FillConfig.FILL_MAX_UNITS), + style = MaterialTheme.typography.bodyMedium + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(WheelItemHeight * WheelVisibleRows) + ) { + CarelevoWheelPicker( + values = values, + selectedValue = selectedValue, + onValueSelected = { selectedValue = it } + ) + } + } +} + +private val WheelItemHeight = 52.dp +private const val WheelVisibleRows = 5 + +@Composable +private fun CarelevoWheelPicker( + values: List, + selectedValue: Int, + onValueSelected: (Int) -> Unit +) { + val initialIndex = remember(values, selectedValue) { + values.indexOf(selectedValue).coerceAtLeast(0) + } + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = initialIndex + ) + val flingBehavior = rememberSnapFlingBehavior(lazyListState = listState) + val centerRowIndex = WheelVisibleRows / 2 + val verticalContentPadding = WheelItemHeight * centerRowIndex + val coroutineScope = rememberCoroutineScope() + var centeredIndex by remember(values, selectedValue) { mutableIntStateOf(initialIndex) } + + LaunchedEffect(listState, values) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo } + .filter { it.isNotEmpty() } + .map { visibleItems -> + val viewportCenter = (listState.layoutInfo.viewportStartOffset + listState.layoutInfo.viewportEndOffset) / 2 + visibleItems.minByOrNull { item -> + abs((item.offset + item.size / 2) - viewportCenter) + }?.index?.coerceIn(0, values.lastIndex) ?: centeredIndex + } + .distinctUntilChanged() + .collect { selectedIndex -> + centeredIndex = selectedIndex + onValueSelected(values[selectedIndex]) + } + } + + LaunchedEffect(selectedValue, values) { + val selectedIndex = values.indexOf(selectedValue) + if (selectedIndex >= 0 && selectedIndex != centeredIndex) { + centeredIndex = selectedIndex + listState.animateScrollToItem(selectedIndex) + } + } + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + LazyColumn( + state = listState, + flingBehavior = flingBehavior, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(vertical = verticalContentPadding), + horizontalAlignment = Alignment.CenterHorizontally + ) { + itemsIndexed(values) { index, value -> + val isSelected = index == centeredIndex + WheelRow( + value = value, + isSelected = isSelected, + onClick = { + coroutineScope.launch { + centeredIndex = index + listState.animateScrollToItem(index) + } + } + ) + } + } + + WheelSelectionOverlay() + } +} + +@Composable +private fun WheelRow( + value: Int, + isSelected: Boolean, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(WheelItemHeight) + .padding(horizontal = 24.dp) + .clip(RoundedCornerShape(14.dp)) + .clickable(onClick = onClick) + .background( + if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.10f) + } else { + Color.Transparent + } + ), + contentAlignment = Alignment.Center + ) { + Text( + text = value.toString(), + style = if (isSelected) { + MaterialTheme.typography.headlineSmall + } else { + MaterialTheme.typography.titleMedium + }, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ) + } +} + +@Composable +private fun WheelSelectionOverlay() { + val overlayHeight = WheelItemHeight + Box( + modifier = Modifier + .fillMaxWidth() + .height(overlayHeight) + .padding(horizontal = 24.dp) + .clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.18f)) + ) { + HorizontalDivider(modifier = Modifier.align(Alignment.TopCenter)) + HorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter)) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/config/CarelevoConfig.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/config/CarelevoConfig.kt new file mode 100644 index 000000000000..3d7ebcb57044 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/config/CarelevoConfig.kt @@ -0,0 +1,38 @@ +package app.aaps.pump.carelevo.config + +class BleEnvConfig { + companion object { + + const val BLE_CCC_DESCRIPTOR = "00002902-0000-1000-8000-00805f9b34fb" + const val BLE_SERVICE_UUID = "e1b40001-ffc4-4daa-a49b-1c92f99072ab" + const val BLE_TX_CHAR_UUID = "e1b40003-ffc4-4daa-a49b-1c92f99072ab" + const val BLE_RX_CHAR_UUID = "e1b40002-ffc4-4daa-a49b-1c92f99072ab" + } +} + +/** + * Insulin fill limits of the patch reservoir. Single source of truth for the wizard's amount + * picker AND the user-facing range texts — must stay consistent with + * `PumpType.CAREMEDI_CARELEVO.maxReservoirReading` (300) in `core:data`. + */ +class FillConfig { + companion object { + + const val FILL_MIN_UNITS = 50 + const val FILL_MAX_UNITS = 300 + const val FILL_STEP_UNITS = 10 + } +} + +class PrefEnvConfig { + companion object { + + const val PATCH_INFO = "carelevo_patch_info" + const val BASAL_INFUSION_INFO = "carelevo_basal_infusion_info" + const val TEMP_BASAL_INFUSION_INFO = "carelevo_temp_basal_infusion_info" + const val IMME_BOLUS_INFUSION_INFO = "carelevo_imme_bolus_infusion_info" + const val EXTEND_BOLUS_INFUSION_INFO = "carelevo_extend_bolus_infusion_info" + const val USER_SETTING_INFO = "carelevo_user_setting_info" + const val CARELEVO_ALARM_INFO_LIST = "carelevo_alarm_info_list" + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoAlarmClearCoordinator.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoAlarmClearCoordinator.kt new file mode 100644 index 000000000000..741512d18ce4 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoAlarmClearCoordinator.kt @@ -0,0 +1,100 @@ +package app.aaps.pump.carelevo.coordinator + +import app.aaps.core.data.pump.defs.PumpType +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.pump.carelevo.ble.CarelevoBleTransport +import app.aaps.pump.carelevo.command.CmdAlarmClear +import app.aaps.pump.carelevo.command.CmdAlarmClearPatchDiscard +import app.aaps.pump.carelevo.command.CmdPumpResume +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.jvm.optionals.getOrNull + +/** + * The alarm-clear BLE operations, shared by both clear-alarm surfaces (the Android notification action in + * [app.aaps.pump.carelevo.common.CarelevoAlarmActionHandler] and the in-app alarm screen in + * [app.aaps.pump.carelevo.presentation.viewmodel.CarelevoAlarmViewModel]). The patch-facing ops go through + * the AAPS CommandQueue so a resting (idle-disconnected) pump is reconnected before the op runs — a direct + * write would silently no-op, and the callers' old `isPatchConnected()` gate would otherwise fall to the + * destructive force-quit path. Each caller keeps its own alarm-cause branching, alarm-queue state and UI. + */ +@Singleton +class CarelevoAlarmClearCoordinator @Inject constructor( + private val aapsLogger: AAPSLogger, + private val commandQueue: CommandQueue, + private val carelevoPatch: CarelevoPatch, + private val transport: CarelevoBleTransport, + private val pumpSync: PumpSync, + private val dateUtil: DateUtil +) { + + // Serialize the queue ops across BOTH surfaces (this is a @Singleton): CommandQueue.customCommand dedups + // by command CLASS and returns success=false if one of the same class is already in-flight, so two + // concurrent clears (notification + in-app, or two different alarms) would make the second misread as a + // clear-failure and locally dismiss its alarm without ever clearing it on the patch. The mutex is held + // for the whole command round-trip, so the second op waits and then runs its own clear. + private val opMutex = Mutex() + + /** Acknowledge + clear the alarm ON the patch. Returns true when the patch confirmed the clear. */ + suspend fun clearAlarmOnPatch(info: CarelevoAlarmInfo): Boolean = opMutex.withLock { + commandQueue.customCommand(CmdAlarmClear(info.alarmId, info.alarmType, info.cause)).success + } + + /** Tell the patch to discard itself in response to a warning alarm. Returns true on confirmation. */ + suspend fun discardOnAlarm(info: CarelevoAlarmInfo): Boolean = opMutex.withLock { + commandQueue.customCommand(CmdAlarmClearPatchDiscard(info.alarmId, info.alarmType, info.cause)).success + } + + /** + * Can a discard op plausibly reach the patch? Used by the patch-ABANDON paths (serious warning + * discard) to decide between a graceful queue discard and an immediate local force-quit. With + * per-op sessions there is no resting link to inspect — "reachable" means a session can be + * attempted at all (patch paired + Bluetooth on); a genuinely dead patch then fails the bounded + * session connect (~20 s) and falls to force-quit. + */ + fun isPatchReachable(): Boolean = + carelevoPatch.getPatchInfoAddress() != null && carelevoPatch.isBluetoothEnabled() + + /** Resume infusion after an auto-suspend alarm; syncs the TBR-cancel to NS on success. */ + suspend fun resumeInfusion(): Boolean = opMutex.withLock { + val success = commandQueue.customCommand(CmdPumpResume()).success + if (success) { + // Only record a TBR stop if AAPS actually believes one is running — an unguarded stop + // would write a spurious end-marker into treatment history on a plain resume. + if (pumpSync.expectedPumpState().temporaryBasal != null) { + pumpSync.syncStopTemporaryBasalWithPumpId( + timestamp = dateUtil.now(), + endPumpId = dateUtil.now(), + pumpType = PumpType.CAREMEDI_CARELEVO, + pumpSerial = carelevoPatch.patchInfo.value?.getOrNull()?.manufactureNumber ?: "" + ) + } + } + success + } + + /** + * Last-resort LOCAL teardown when the patch is being abandoned (patch-discard alarm, or the patch is + * genuinely unreachable): clear the bond + flush local patch state. Deliberately NOT queued — it is a + * one-way abandon (no resting link exists to drop). [onComplete] (the caller's alarm-queue clear) + * runs AFTER unbond+flush — and runs even if the unbond errors — so the local alarm queue is only + * cleared once the patch state has actually been flushed. + */ + fun forceQuitTeardown(onComplete: () -> Unit) { + try { + carelevoPatch.getPatchInfoAddress()?.let { transport.adapter.removeBond(it.uppercase()) } + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "forceQuitTeardown.unbondError", e) + } + carelevoPatch.flushPatchInformation() + onComplete() + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoBasalProfileUpdateCoordinator.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoBasalProfileUpdateCoordinator.kt new file mode 100644 index 000000000000..9348e3765bb2 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoBasalProfileUpdateCoordinator.kt @@ -0,0 +1,203 @@ +package app.aaps.pump.carelevo.coordinator + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.profile.Profile +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.ble.CarelevoBleSession +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.result.ResultSuccess +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import app.aaps.pump.carelevo.domain.usecase.basal.CarelevoSetBasalProgramUseCase +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.runBlocking +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton +import kotlin.jvm.optionals.getOrNull + +@Singleton +class CarelevoBasalProfileUpdateCoordinator @Inject constructor( + private val aapsLogger: AAPSLogger, + private val rh: ResourceHelper, + private val pumpEnactResultProvider: Provider, + private val carelevoPatch: CarelevoPatch, + private val bleSession: CarelevoBleSession, + private val setBasalProgramUseCase: CarelevoSetBasalProgramUseCase +) { + + private companion object { + + // Fresh-session worst case: 1 s settle + ≤20 s connect handshake + ≤30 s for the three program + // writes — the session's own withTimeouts fire first, this is only the outer backstop. + private const val BASAL_PROGRAM_TIMEOUT_SEC = 60L + } + + private var lastProfileUpdateAttemptMs: Long = 0 + + fun updateBasalProfile( + profile: Profile, + cancelExtendedBolus: () -> PumpEnactResult, + cancelTempBasal: () -> PumpEnactResult, + onProfileUpdated: (Profile) -> Unit + ): PumpEnactResult { + aapsLogger.debug(LTag.PUMPCOMM, "execute.start profile=$profile") + + val result = pumpEnactResultProvider.get() + val now = System.currentTimeMillis() + if (now - lastProfileUpdateAttemptMs < 30_000) { + // Benign debounce: a repeated profile-set within 30s is skipped, not a real failure. + // Do not post FAILED_UPDATE_PROFILE here — dev reclassified it to URGENT, so posting it + // for this non-event would ring the alarm. Log only; the real failure branches below post. + aapsLogger.debug(LTag.PUMPCOMM, "execute.skip tooSoon=true") + return result + .success(true) + .enacted(false) + .comment(rh.gs(R.string.carelevo_profile_update_skip_comment)) + } + + val infusionInfo = carelevoPatch.infusionInfo.value?.getOrNull() + val shouldUseSetBasalProgram = infusionInfo?.basalInfusionInfo == null + + val response = cancelExtendedBolusRx(infusionInfo, cancelExtendedBolus) + .timeout(20, TimeUnit.SECONDS) + .retryCancelWithLog("cancelExtendedBolus") + .flatMap { + if (!it.success) throw IllegalStateException("cancelExtendedBolus failed") + cancelTempBasalRx(infusionInfo, cancelTempBasal) + .timeout(20, TimeUnit.SECONDS) + .retryCancelWithLog("cancelTempBasal") + } + .flatMap { + if (!it.success) throw IllegalStateException("cancelTempBasal failed") + // The program opens a fresh session (connect ≤20 s + three writes ≤30 s); its own internal + // timeouts fire first, this outer one is only a safety net. + executeBasalProgram(profile, shouldUseSetBasalProgram).timeout(BASAL_PROGRAM_TIMEOUT_SEC, TimeUnit.SECONDS) + } + .onErrorReturn { ResponseResult.Error(it) } + .blockingGet() + + // PROFILE_SET_OK / FAILED_UPDATE_PROFILE are now posted centrally by the CommandQueue from the + // returned success/enacted (success && enacted → PROFILE_SET_OK; !success → FAILED_UPDATE_PROFILE), + // unified across all pumps — do not post them here. + return when (response) { + is ResponseResult.Success -> { + aapsLogger.debug(LTag.PUMPCOMM, "execute.success") + onProfileUpdated(profile) + lastProfileUpdateAttemptMs = System.currentTimeMillis() + result.success(true).enacted(true) + } + + is ResponseResult.Error -> { + aapsLogger.error(LTag.PUMPCOMM, "execute.error error=${response.e}", response.e) + lastProfileUpdateAttemptMs = System.currentTimeMillis() + result.success(false).enacted(false) + } + + is ResponseResult.Failure -> { + aapsLogger.error(LTag.PUMPCOMM, "execute.failure unknownResponse=$response") + lastProfileUpdateAttemptMs = System.currentTimeMillis() + result.success(false).enacted(false) + } + } + } + + private fun Single.retryCancelWithLog( + tag: String, + maxRetry: Int = 3, + delayMs: Long = 300L + ): Single { + return retryWhen { errors -> + errors + .zipWith(Flowable.range(1, maxRetry)) { error, retryCount -> + if (retryCount < maxRetry) { + aapsLogger.warn(LTag.PUMPCOMM, "$tag.retry attempt=$retryCount/$maxRetry reason=${error.message}") + retryCount + } else { + aapsLogger.error(LTag.PUMPCOMM, "$tag.retry.exhausted max=$maxRetry reason=${error.message}") + throw error + } + } + .flatMap { Flowable.timer(delayMs, TimeUnit.MILLISECONDS) } + } + } + + private fun executeBasalProgram( + profile: Profile, + shouldUseSetBasalProgram: Boolean + ): Single> { + aapsLogger.debug(LTag.PUMPCOMM, "executeBasalProgram mode=${if (shouldUseSetBasalProgram) "SET" else "UPDATE"}") + return Single.fromCallable { executeBasalProgramInternal(profile, isUpdate = !shouldUseSetBasalProgram) } + } + + /** + * Basal (re)program core (**delivery-critical**). Same 3-write single-session plan as the activation + * set-basal in `CarelevoActivationExecutor.runSetBasal`, with [isUpdate] selecting the change opcode + * (0x21) instead of the set opcode (0x13). The V2 update's persist is byte-identical to set's, so both + * modes reuse [CarelevoSetBasalProgramUseCase]'s `buildBasalProgramPlan` + `persistBasalProgram` + * (mode=1 + basal infusion-info). + */ + private fun executeBasalProgramInternal(profile: Profile, isUpdate: Boolean): ResponseResult = runCatching { + val address = carelevoPatch.getPatchInfoAddress() + ?: throw IllegalStateException("no patch address") + val plan = setBasalProgramUseCase.buildBasalProgramPlan(profile) + val programmed = runBlocking { bleSession.runBasalProgram(address, plan.programs, isUpdate) } + if (!programmed) throw IllegalStateException("basal program write failed") + val persisted = setBasalProgramUseCase.persistBasalProgram(plan.segments) + aapsLogger.info(LTag.PUMPCOMM, "newBle.basalProfile isUpdate=$isUpdate programmed=true persisted=$persisted") + if (!persisted) throw IllegalStateException("basal program persist failed") + ResultSuccess + }.fold( + onSuccess = { ResponseResult.Success(it) }, + onFailure = { + aapsLogger.error(LTag.PUMPCOMM, "newBle.basalProfile FAILED isUpdate=$isUpdate", it) + ResponseResult.Error(it) + } + ) + + private fun cancelExtendedBolusRx( + infusionInfo: CarelevoInfusionInfoDomainModel?, + cancelExtendedBolus: () -> PumpEnactResult + ): Single { + aapsLogger.debug(LTag.PUMPCOMM, "cancelExtendedBolus.start hasExtended=${infusionInfo?.extendBolusInfusionInfo != null}") + return if (infusionInfo?.extendBolusInfusionInfo != null) { + Single.fromCallable { cancelExtendedBolus() } + .flatMap { cancelResult -> + if (cancelResult.success) { + Single.just(cancelResult) + } else { + Single.error(IllegalStateException("cancelExtendedBolus returned success=false")) + } + } + .doOnError { aapsLogger.error(LTag.PUMPCOMM, "cancelExtendedBolus.error", it) } + } else { + Single.just(pumpEnactResultProvider.get().success(true).enacted(false)) + } + } + + private fun cancelTempBasalRx( + infusionInfo: CarelevoInfusionInfoDomainModel?, + cancelTempBasal: () -> PumpEnactResult + ): Single { + aapsLogger.debug(LTag.PUMPCOMM, "cancelTempBasal.start hasTempBasal=${infusionInfo?.tempBasalInfusionInfo != null}") + return if (infusionInfo?.tempBasalInfusionInfo != null) { + Single.fromCallable { cancelTempBasal() } + .flatMap { cancelResult -> + if (cancelResult.success) { + Single.just(cancelResult) + } else { + Single.error(IllegalStateException("cancelTempBasal returned success=false")) + } + } + .doOnError { aapsLogger.error(LTag.PUMPCOMM, "cancelTempBasal.error", it) } + } else { + Single.just(pumpEnactResultProvider.get().success(true).enacted(false)) + } + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoBolusCoordinator.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoBolusCoordinator.kt new file mode 100644 index 000000000000..e7ab0f5170b1 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoBolusCoordinator.kt @@ -0,0 +1,570 @@ +package app.aaps.pump.carelevo.coordinator + +import android.os.SystemClock +import app.aaps.core.data.model.BS +import app.aaps.core.data.pump.defs.PumpType +import app.aaps.core.data.time.T +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.pump.BolusProgressData +import app.aaps.core.interfaces.pump.DetailedBolusInfo +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.pump.PumpInsulin +import app.aaps.core.interfaces.pump.PumpRate +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.interfaces.utils.Round +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.ble.CarelevoBleSession +import app.aaps.pump.carelevo.ble.commands.BolusCancelCommand +import app.aaps.pump.carelevo.ble.commands.ExtendBolusCancelCommand +import app.aaps.pump.carelevo.ble.commands.ExtendBolusCommand +import app.aaps.pump.carelevo.ble.commands.ImmediateBolusCommand +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.coordinator.CarelevoBolusCoordinator.Companion.CANCEL_WAIT_TIMEOUT_MS +import app.aaps.pump.carelevo.coordinator.CarelevoBolusCoordinator.Companion.NEW_STACK_CANCEL_MAX_RETRY +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoCancelExtendBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoCancelImmeBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoFinishImmeBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoStartExtendBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoStartImmeBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.model.StartImmeBolusInfusionResponseModel +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.runBlocking +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton +import kotlin.jvm.optionals.getOrNull +import kotlin.math.min +import kotlin.math.roundToInt + +@Singleton +class CarelevoBolusCoordinator @Inject constructor( + private val aapsLogger: AAPSLogger, + private val rh: ResourceHelper, + private val dateUtil: DateUtil, + private val bolusProgressData: BolusProgressData, + private val pumpSync: PumpSync, + private val aapsSchedulers: AapsSchedulers, + private val pumpEnactResultProvider: Provider, + private val carelevoPatch: CarelevoPatch, + private val bleSession: CarelevoBleSession, + private val startImmeBolusInfusionUseCase: CarelevoStartImmeBolusInfusionUseCase, + private val finishImmeBolusInfusionUseCase: CarelevoFinishImmeBolusInfusionUseCase, + private val cancelImmeBolusInfusionUseCase: CarelevoCancelImmeBolusInfusionUseCase, + private val startExtendBolusInfusionUseCase: CarelevoStartExtendBolusInfusionUseCase, + private val cancelExtendBolusInfusionUseCase: CarelevoCancelExtendBolusInfusionUseCase +) { + + companion object { + + private const val STOP_BOLUS_TIME_OUT = 15_000L + private const val PROGRESS_POLL_MS = 100L + private const val RESULT_SUCCESS = 0 + + // The out-of-band cancel opens a fresh GATT per attempt, so cap retries low — a single bounded + // connect+write settles a reachable patch; more attempts only lengthen the two-GATT-collision + // window against an unreachable one (which can't be stopped anyway). + private const val NEW_STACK_CANCEL_MAX_RETRY = 1 + + // Backstop only: the guard normally releases the moment the cancel session finishes (via + // immeBolusCancelInFlight), not on this timer. Sized to comfortably exceed a bounded cancel span so the + // deadline never fires mid-attempt; it just prevents an unexpected hang from blocking the worker forever. + private const val CANCEL_WAIT_TIMEOUT_MS = 60_000L + } + + @Volatile private var isImmeBolusStop = false + + // True for the WHOLE out-of-band cancel span (across retries) — the delivery worker's guard blocks + // while this is set so it can't re-dial the primary link while the cancel's GATT is open (two-GATT status-133). + @Volatile private var immeBolusCancelInFlight = false + + // Written on the queue worker (handleBolusSuccess), read from the out-of-band cancel thread + // (calculateMaxRetry in cancelImmediateBolusInternal) — needs the same visibility guarantee + // as its two sibling flags above. + @Volatile private var bolusExpectMs: Long = 0 + private val _lastBolusTime = MutableStateFlow(null) + val lastBolusTime: StateFlow = _lastBolusTime + + private val _lastBolusAmount = MutableStateFlow(null) + val lastBolusAmount: StateFlow = _lastBolusAmount + + fun deliverTreatment( + detailedBolusInfo: DetailedBolusInfo, + serialNumber: String, + onLastDataUpdated: () -> Unit, + pluginDisposable: CompositeDisposable + ): PumpEnactResult { + aapsLogger.debug(LTag.PUMPCOMM, "deliverTreatment.start bolusType=${detailedBolusInfo.bolusType}") + require(detailedBolusInfo.carbs == 0.0) { detailedBolusInfo.toString() } + require(detailedBolusInfo.insulin > 0) { detailedBolusInfo.toString() } + + val result = pumpEnactResultProvider.get() + if (!carelevoPatch.isBluetoothEnabled()) { + return result + } + + val infusionInfo = carelevoPatch.infusionInfo.value?.getOrNull() + aapsLogger.warn( + LTag.PUMPCOMM, + "deliverTreatment.gate type=${detailedBolusInfo.bolusType}, " + + "immeInfo=${infusionInfo?.immeBolusInfusionInfo}" + ) + if (infusionInfo?.immeBolusInfusionInfo != null) { + aapsLogger.warn(LTag.PUMPCOMM, "deliverTreatment.reject reason=immeBolusInProgress") + result.success = false + result.enacted = false + result.bolusDelivered = 0.0 + result.comment("Another bolus is in progress") + return result + } + + isImmeBolusStop = false + val actionId = (carelevoPatch.patchInfo.value?.getOrNull()?.bolusActionSeq ?: 0) + 1 + val normalizedActionId = if (actionId <= 0) 1 else ((actionId - 1) % 255) + 1 + + return deliverTreatmentInternal(detailedBolusInfo, normalizedActionId, result, serialNumber, onLastDataUpdated, pluginDisposable) + } + + fun cancelImmediateBolus( + serialNumber: String, + onLastDataUpdated: () -> Unit + ) = cancelImmediateBolusInternal(serialNumber, onLastDataUpdated) + + fun setExtendedBolus( + insulin: Double, + durationInMinutes: Int, + serialNumber: String + ): PumpEnactResult { + // Same fail-fast guard style as deliverTreatment: durationInMinutes==0 would otherwise + // produce speed=Infinity, silently mangled into wire bytes. + require(insulin > 0.0) { "extended bolus insulin must be > 0, got $insulin" } + require(durationInMinutes > 0) { "extended bolus duration must be > 0 min, got $durationInMinutes" } + val result = pumpEnactResultProvider.get() + if (!carelevoPatch.isBluetoothEnabled()) return result + return setExtendedBolusInternal(insulin, durationInMinutes, serialNumber) + } + + fun cancelExtendedBolus( + serialNumber: String, + onLastDataUpdated: () -> Unit + ): PumpEnactResult { + val result = pumpEnactResultProvider.get() + if (!carelevoPatch.isBluetoothEnabled()) return result + return cancelExtendedBolusInternal(serialNumber, onLastDataUpdated) + } + + /** + * Set-extended-bolus core (**delivery-critical**). Discrete `ExtendBolusCommand` (0x25→0x85) on the + * session — **`immediateDose` is always 0.0** (pure extended; a non-zero value injects an unintended + * upfront dose). On `resultCode==0` reuse the use case's `mode=5` persist → then + * `pumpSync.syncExtendedBolusWithPumpId`. + */ + private fun setExtendedBolusInternal( + insulin: Double, + durationInMinutes: Int, + serialNumber: String + ): PumpEnactResult { + val result = pumpEnactResultProvider.get() + val address = carelevoPatch.getPatchInfoAddress() + ?: return result.success(false).enacted(false).comment("no patch address") + val hour = durationInMinutes / 60 + val min = durationInMinutes % 60 + val speed = insulin / (durationInMinutes.toDouble() / 60) + return try { + val response = runBlocking { bleSession.runSingle(address, ExtendBolusCommand(immediateDose = 0.0, extendedSpeed = speed, hour = hour, min = min)) } + val success = response.resultCode == RESULT_SUCCESS + // Once the pump ACKs, the extended bolus IS running — pumpSync must record it even if the + // local persist fails (otherwise IOB silently diverges from what the patch delivers). + val persisted = success && startExtendBolusInfusionUseCase.persistExtendBolusStarted(volume = insulin, speed = speed, minutes = durationInMinutes) + aapsLogger.info(LTag.PUMPCOMM, "newBle.setExtendedBolus insulin=$insulin result=${response.resultCode} persisted=$persisted") + if (success && !persisted) aapsLogger.error(LTag.PUMPCOMM, "newBle.setExtendedBolus persist failed — pump enacted, recording in pumpSync anyway") + if (success) { + runBlocking { + pumpSync.syncExtendedBolusWithPumpId( + timestamp = dateUtil.now(), + rate = PumpRate(insulin), + duration = T.mins(durationInMinutes.toLong()).msecs(), + isEmulatingTB = false, + pumpId = dateUtil.now(), + pumpType = PumpType.CAREMEDI_CARELEVO, + pumpSerial = serialNumber + ) + } + result.success = true + result.enacted = true + result + } else { + result.success = false + result.enacted = false + result + } + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.setExtendedBolus FAILED", e) + result.success = false + result.enacted = false + result + } + } + + /** + * Cancel-extended-bolus core. Discrete `ExtendBolusCancelCommand` (0x29→0x89, response carries + * `infusedAmount`) on the session → delete + recompute-mode persist → + * `pumpSync.syncStopExtendedBolusWithPumpId`. `infusedAmount` is logged only (not reconciled into the DB). + */ + private fun cancelExtendedBolusInternal( + serialNumber: String, + onLastDataUpdated: () -> Unit + ): PumpEnactResult { + val result = pumpEnactResultProvider.get() + val address = carelevoPatch.getPatchInfoAddress() + ?: return result.success(false).enacted(false).comment("no patch address") + return try { + val response = runBlocking { bleSession.runSingle(address, ExtendBolusCancelCommand()) } + val success = response.resultCode == RESULT_SUCCESS + // Same pump-is-authoritative rule as the start path: on ACK the extended bolus IS stopped + // on the patch, so the stop must reach pumpSync even if the local persist fails. + val persisted = success && cancelExtendBolusInfusionUseCase.persistExtendBolusCancelled() + aapsLogger.info(LTag.PUMPCOMM, "newBle.cancelExtendedBolus result=${response.resultCode} infused=${response.infusedAmount} persisted=$persisted") + if (success && !persisted) aapsLogger.error(LTag.PUMPCOMM, "newBle.cancelExtendedBolus persist failed — pump stopped, recording stop in pumpSync anyway") + if (success) { + onLastDataUpdated() + runBlocking { + pumpSync.syncStopExtendedBolusWithPumpId( + timestamp = dateUtil.now(), + endPumpId = dateUtil.now(), + pumpType = PumpType.CAREMEDI_CARELEVO, + pumpSerial = serialNumber + ) + } + result.success = true + result.enacted = true + result.isTempCancel = true + result + } else { + result.success = false + result.enacted = false + result + } + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.cancelExtendedBolus FAILED", e) + result.success = false + result.enacted = false + result + } + } + + /** + * Immediate-bolus core (**delivery-critical**, Option A). Discrete `ImmediateBolusCommand` (0x24→0x84, + * `actionId` echoed as a stricter correlation guard) on the session; on `resultCode==0` persist + * the start (`mode=3`) then run the synthetic progress loop ([handleBolusSuccess]) with a synthesized + * success response — the pump sends no progress/completion frames, so the loop needs no BLE and runs + * after the start session has already closed. + */ + private fun deliverTreatmentInternal( + detailedBolusInfo: DetailedBolusInfo, + actionId: Int, + result: PumpEnactResult, + serialNumber: String, + onLastDataUpdated: () -> Unit, + pluginDisposable: CompositeDisposable + ): PumpEnactResult { + val address = carelevoPatch.getPatchInfoAddress() + if (address == null) { + result.success = false + result.enacted = false + result.bolusDelivered = 0.0 + return result.comment("no patch address") + } + return try { + val response = runBlocking { bleSession.runSingle(address, ImmediateBolusCommand(actionId, detailedBolusInfo.insulin)) } + if (response.resultCode != RESULT_SUCCESS) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.deliverTreatment start failed result=${response.resultCode}") + result.success = false + result.enacted = false + result.bolusDelivered = 0.0 + return result.comment(rh.gs(R.string.alarm_feat_msg_check_patch_connect)) + } + // The pump ACKed (resultCode==0) — insulin IS being delivered from this point on. The + // local persist is bookkeeping only: if it fails we MUST still run the delivery loop and + // record the bolus in pumpSync, otherwise a delivered dose would vanish from IOB and the + // loop could stack insulin on top of it. Persist failure is logged as a secondary fault. + if (!startImmeBolusInfusionUseCase.persistImmeBolusStarted(actionId, detailedBolusInfo.insulin, response.expectedCompletionSeconds)) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.deliverTreatment persist failed — continuing, pump is delivering (bookkeeping will recover on next status read)") + } + aapsLogger.info(LTag.PUMPCOMM, "ble.deliverTreatment start insulin=${detailedBolusInfo.insulin} expectSec=${response.expectedCompletionSeconds}") + // Start session has closed; run the synthetic progress loop with NO link (Option A) via + // handleBolusSuccess with a synthesized success response. + handleBolusSuccess( + ResponseResult.Success(StartImmeBolusInfusionResponseModel(expectSec = response.expectedCompletionSeconds)), + detailedBolusInfo, + result, + serialNumber, + onLastDataUpdated, + pluginDisposable + ) + awaitImmeBolusCancelIfStopped() + result + } catch (e: Exception) { + handleBolusError(e, result) + result + } + } + + /** + * Option A concurrency guard: if the user stopped the bolus, keep the queue worker blocked here until the + * OUT-OF-BAND cancel session has confirmed ([isImmeBolusStop]) — otherwise the freed worker could re-dial + * the primary link while the cancel's session is still open (two GATTs → status-133). Bounded + * by [CANCEL_WAIT_TIMEOUT_MS] so an unreachable-pump cancel can't hang the worker; a reachable patch + * settles in ~1-2 s. + */ + private fun awaitImmeBolusCancelIfStopped() { + if (!bolusProgressData.isStopPressed || isImmeBolusStop) return + val deadline = System.currentTimeMillis() + CANCEL_WAIT_TIMEOUT_MS + // Block until the out-of-band cancel session has fully finished (its GATT closed) before freeing the + // worker. Exit on: confirmed cancel (isImmeBolusStop); OR the cancel ran and finished without confirming + // (engaged then cleared → gave up, so no GATT is open → safe); OR the backstop deadline. + var engaged = false + while (System.currentTimeMillis() < deadline) { + if (isImmeBolusStop) break + if (immeBolusCancelInFlight) engaged = true else if (engaged) break + SystemClock.sleep(PROGRESS_POLL_MS) + } + aapsLogger.info(LTag.PUMPCOMM, "newBle.deliverTreatment stop settled isImmeBolusStop=$isImmeBolusStop inFlight=$immeBolusCancelInFlight") + } + + /** + * Immediate-bolus cancel core (Option A). Stop arrives OUT-OF-BAND (off the queue worker, via + * `cancelAllBoluses`) while the delivery loop runs with no link, so this opens a FRESH cancel session — + * the session mutex serializes it against any in-flight session (the start session has already closed). + * Discrete `BolusCancelCommand` (0x2C→0x8C); on success record the pump-reported partial + * `infusedAmount` ([PumpSync]) + delete/recompute persist + set [isImmeBolusStop] (which breaks the + * delivery loop). Retries ONLY a timed-out attempt up to a small [NEW_STACK_CANCEL_MAX_RETRY] — each + * attempt is a full fresh connect, so more retries only lengthen the two-GATT window. + * + * HARDWARE ASSUMPTION (load-bearing for the retry): re-sending `BolusCancelCommand` after a + * timed-out attempt whose original write DID reach the pump must be a harmless no-op on an + * already-cancelled/finished bolus (non-SUCCESS result, no side effect). This is not verifiable + * in software — revalidate if the firmware protocol ever changes. + * [immeBolusCancelInFlight] spans the whole call so the delivery worker's guard blocks until the + * cancel's GATT is closed. + */ + private fun cancelImmediateBolusInternal(serialNumber: String, onLastDataUpdated: () -> Unit) { + immeBolusCancelInFlight = true + try { + val address = carelevoPatch.getPatchInfoAddress() + if (address == null) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.cancelImmediateBolus no patch address") + return + } + val maxRetry = min(calculateMaxRetry(totalAllowedMs = bolusExpectMs).coerceAtLeast(0), NEW_STACK_CANCEL_MAX_RETRY) + var attempt = 0 + while (attempt <= maxRetry && !isImmeBolusStop) { + try { + val response = runBlocking { bleSession.runSingle(address, BolusCancelCommand()) } + if (response.resultCode == RESULT_SUCCESS) { + onLastDataUpdated() + val infusedAmount = response.infusedAmount + bolusProgressData.updateProgress( + bolusProgressData.state.value?.percent ?: 100, + rh.gs(app.aaps.core.interfaces.R.string.bolus_delivered_successfully, infusedAmount.toFloat()), + PumpInsulin(infusedAmount) + ) + val persisted = cancelImmeBolusInfusionUseCase.persistImmeBolusCancelled() + if (!persisted) aapsLogger.error(LTag.PUMPCOMM, "newBle.cancelImmediateBolus persist failed — stale local bolus record until next status read") + runBlocking { + pumpSync.syncBolusWithPumpId( + dateUtil.now(), + PumpInsulin(infusedAmount), + BS.Type.NORMAL, + dateUtil.now(), + PumpType.CAREMEDI_CARELEVO, + serialNumber + ) + } + isImmeBolusStop = true + aapsLogger.info(LTag.PUMPCOMM, "newBle.cancelImmediateBolus infused=$infusedAmount") + return + } + aapsLogger.error(LTag.PUMPCOMM, "newBle.cancelImmediateBolus failed result=${response.resultCode}") + return + } catch (_: TimeoutCancellationException) { + // Only a timeout is worth another fresh-connect attempt. + aapsLogger.error(LTag.PUMPCOMM, "newBle.cancelImmediateBolus timeout attempt=$attempt") + attempt++ + } catch (e: Exception) { + // Any other failure (connect refused, BLE error): give up — do not retry. + aapsLogger.error(LTag.PUMPCOMM, "newBle.cancelImmediateBolus error=$e") + return + } + } + if (!isImmeBolusStop) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.cancelImmediateBolus exhausted maxRetry=$maxRetry") + } + } finally { + immeBolusCancelInFlight = false + } + } + + private fun handleBolusSuccess( + response: ResponseResult<*>, + detailedInfo: DetailedBolusInfo, + result: PumpEnactResult, + serialNumber: String, + onLastDataUpdated: () -> Unit, + pluginDisposable: CompositeDisposable + ) { + if (response !is ResponseResult.Success) { + val message = when (response) { + is ResponseResult.Failure -> response.message + is ResponseResult.Error -> response.e.message ?: response.e.toString() + } + aapsLogger.error(LTag.PUMPCOMM, "deliverTreatment.nonSuccess response=$response") + result.success = false + result.enacted = false + result.bolusDelivered = 0.0 + result.comment(message) + return + } + + val data = response.data as StartImmeBolusInfusionResponseModel + + val now = System.currentTimeMillis() + onLastDataUpdated() + _lastBolusTime.value = now + _lastBolusAmount.value = PumpInsulin(detailedInfo.insulin) + + val stepUnit = 0.05 + val totalInsulin = detailedInfo.insulin + val totalSteps = (Round.ceilTo(totalInsulin, stepUnit) / stepUnit).roundToInt().coerceAtLeast(1) + + bolusExpectMs = data.expectSec * 1000L + val delayMs = bolusExpectMs / totalSteps + + var completed = false + for (step in 0..totalSteps) { + // Cancellation-cooperative: break promptly when the user presses stop. + // isStopPressed is the core flag flipped the instant the user taps stop + // (CommandQueue.cancelAllBoluses), while isImmeBolusStop is only set later once the + // pump confirms the cancel. Poll both so the progress loop stops advancing immediately + // instead of only after the pump round-trip. + if (isImmeBolusStop || bolusProgressData.isStopPressed) break + + if (step == totalSteps) { + bolusProgressData.updateProgress( + 100, + rh.gs( + app.aaps.core.interfaces.R.string.bolus_delivered_successfully, + detailedInfo.insulin.toFloat() + ), + PumpInsulin(detailedInfo.insulin) + ) + runBlocking { + pumpSync.syncBolusWithPumpId( + detailedInfo.timestamp, + PumpInsulin(detailedInfo.insulin), + detailedInfo.bolusType, + dateUtil.now(), + PumpType.CAREMEDI_CARELEVO, + serialNumber + ) + } + handleFinishImmeBolus(onLastDataUpdated, pluginDisposable) + completed = true + } else { + // Sleep the per-step delay in short slices so a mid-step stop is honored within + // PROGRESS_POLL_MS rather than after the full delayMs (SystemClock.sleep is blocking + // and does not observe coroutine cancellation). + var slept = 0L + while (slept < delayMs && !isImmeBolusStop && !bolusProgressData.isStopPressed) { + val chunk = min(PROGRESS_POLL_MS, delayMs - slept) + SystemClock.sleep(chunk) + slept += chunk + } + if (isImmeBolusStop || bolusProgressData.isStopPressed) break + val delivering = min(step * stepUnit, detailedInfo.insulin) + val percent = if (totalInsulin <= 0.0) 0 else ((delivering / totalInsulin) * 100).toInt() + bolusProgressData.updateProgress( + percent, + rh.gs(app.aaps.core.interfaces.R.string.bolus_delivering, delivering), + PumpInsulin(delivering) + ) + } + } + + if (completed) { + result.success = true + result.enacted = true + result.bolusDelivered = detailedInfo.insulin + } else { + // User stopped mid-bolus (loop broke before the finish step). The actual partial amount is + // recorded separately by the cancel path via syncBolusWithPumpId(infusedAmount), so don't + // report the full requested dose as delivered. Matches VirtualPumpPlugin's stop contract. + result.success = false + result.enacted = false + result.bolusDelivered = 0.0 + result.comment(rh.gs(app.aaps.core.ui.R.string.stop)) + } + } + + private fun handleBolusError(e: Throwable, result: PumpEnactResult) { + aapsLogger.error(LTag.PUMPCOMM, "deliverTreatment.error error=$e") + result.success = false + result.enacted = false + result.bolusDelivered = 0.0 + if (e is TimeoutException) { + result.comment(rh.gs(R.string.alarm_feat_msg_check_patch_connect)) + } + } + + private fun handleFinishImmeBolus( + onLastDataUpdated: () -> Unit, + pluginDisposable: CompositeDisposable + ) { + pluginDisposable += finishImmeBolusInfusionUseCase.execute() + .observeOn(aapsSchedulers.main) + .subscribeOn(aapsSchedulers.io) + .timeout(3000L, TimeUnit.MILLISECONDS) + .subscribe( + { response -> + when (response) { + is ResponseResult.Success -> { + onLastDataUpdated() + aapsLogger.debug(LTag.PUMPCOMM, "finishImmeBolus.success") + } + + is ResponseResult.Error -> { + aapsLogger.error(LTag.PUMPCOMM, "finishImmeBolus.responseError error=${response.e}") + } + + else -> { + aapsLogger.error(LTag.PUMPCOMM, "finishImmeBolus.failure") + } + } + }, + { e -> + aapsLogger.error(LTag.PUMPCOMM, "finishImmeBolus.subscribeError error=$e") + } + ) + } + + private fun calculateMaxRetry( + totalAllowedMs: Long, + timeoutMs: Long = STOP_BOLUS_TIME_OUT + ): Int { + aapsLogger.debug(LTag.PUMPCOMM, "stopBolus.calculateMaxRetry totalAllowedMs=$totalAllowedMs timeoutMs=$timeoutMs") + if (timeoutMs == 0L) { + return 3 + } + return ((totalAllowedMs + timeoutMs - 1) / timeoutMs).toInt() - 1 + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoConnectionCoordinator.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoConnectionCoordinator.kt new file mode 100644 index 000000000000..2492281d614b --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoConnectionCoordinator.kt @@ -0,0 +1,57 @@ +package app.aaps.pump.carelevo.coordinator + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.pump.carelevo.ble.CarelevoBleSession +import app.aaps.pump.carelevo.common.CarelevoPatch +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.jvm.optionals.getOrNull + +/** + * The CommandQueue's connection border. Once a patch is activated the queue owns a real held link on + * [CarelevoBleSession]: [connect] opens it (one attempt; the queue re-polls [isConnected]), ops reuse it, + * and [disconnect] closes it after the queue drains — the Dash/Medtrum shape. Pre-activation [isConnected] + * is forced true so the queue never dials a missing/unactivated device. [isInitialized] stays + * activation-based. See `_docs/CARELEVO_QUEUE_OWNED_LINK.md`. + */ +@Singleton +class CarelevoConnectionCoordinator @Inject constructor( + private val aapsLogger: AAPSLogger, + private val carelevoPatch: CarelevoPatch, + private val bleSession: CarelevoBleSession +) { + + fun isInitialized(): Boolean { + // Activation-based, NOT connection-based (matches Omnipod Dash / Medtrum): true once the + // patch is paired and its operational status has been read at least once. Must stay true with + // no link up, otherwise the loop's applyTBRRequest / applySMBRequest gate aborts with "pump + // not initialized" during the normal resting state. + val patchInfo = carelevoPatch.patchInfo.value?.getOrNull() ?: return false + return patchInfo.mode != null || + patchInfo.runningMinutes != null || + patchInfo.pumpState != null + } + + /** + * Pre-activation → always true so the queue never dials a missing/unactivated device (the Medtrum + * fallback). Post-activation → the real held-link state, so the QueueWorker's connect-before-execute + * loop drives [connect]/[disconnect] against a link it actually owns. + */ + fun isConnected(): Boolean = if (!isInitialized()) true else bleSession.connected.value + + fun isConnecting(): Boolean = bleSession.isConnecting.value + + /** Fire one connect attempt against the stored patch MAC; the queue re-polls [isConnected]. */ + fun connect(reason: String) { + val address = carelevoPatch.getPatchInfoAddress() ?: run { + aapsLogger.debug(LTag.PUMPCOMM, "connect: no patch address, reason=$reason") + return + } + bleSession.requestConnect(address, reason) + } + + fun disconnect(reason: String) = bleSession.requestDisconnect(reason) + + fun stopConnecting() = bleSession.requestDisconnect("stopConnecting") +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoSettingsCoordinator.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoSettingsCoordinator.kt new file mode 100644 index 000000000000..f915af14112b --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoSettingsCoordinator.kt @@ -0,0 +1,50 @@ +package app.aaps.pump.carelevo.coordinator + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoDeleteUserSettingInfoUseCase +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The preference-driven patch settings (max bolus, reminders, buzzer) are routed through the AAPS + * CommandQueue as custom commands ([app.aaps.pump.carelevo.command.CarelevoActivationExecutor]); this + * coordinator now only owns the shutdown-time settings clear. + */ +@Singleton +class CarelevoSettingsCoordinator @Inject constructor( + private val aapsLogger: AAPSLogger, + private val aapsSchedulers: AapsSchedulers, + private val deleteUserSettingInfoUseCase: CarelevoDeleteUserSettingInfoUseCase +) { + + // Best-effort DIRECT write, intentionally NOT queued: it runs from the plugin's onStop() during + // shutdown, where reconnecting via the queue is unreliable and would block teardown. Reminder + // settings on the patch are non-critical, so a missed clear on stop is acceptable. + fun clearUserSettings(pluginDisposable: CompositeDisposable) { + pluginDisposable += deleteUserSettingInfoUseCase.execute() + .observeOn(aapsSchedulers.io) + .subscribeOn(aapsSchedulers.io) + .timeout(3000L, TimeUnit.MILLISECONDS) + .subscribe { response -> + when (response) { + is ResponseResult.Success -> { + aapsLogger.debug(LTag.PUMPCOMM, "deleteUserSettingInfo.success") + } + + is ResponseResult.Error -> { + aapsLogger.debug(LTag.PUMPCOMM, "deleteUserSettingInfo.responseError error=${response.e}") + } + + else -> { + aapsLogger.debug(LTag.PUMPCOMM, "deleteUserSettingInfo.failure") + } + } + } + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoTempBasalCoordinator.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoTempBasalCoordinator.kt new file mode 100644 index 000000000000..f5c011098cf6 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoTempBasalCoordinator.kt @@ -0,0 +1,221 @@ +package app.aaps.pump.carelevo.coordinator + +import app.aaps.core.data.pump.defs.PumpType +import app.aaps.core.data.time.T +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.pump.PumpRate +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.pump.carelevo.ble.CarelevoBleSession +import app.aaps.pump.carelevo.ble.commands.TempBasalCancelCommand +import app.aaps.pump.carelevo.ble.commands.TempBasalCommand +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.domain.usecase.basal.CarelevoCancelTempBasalInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.basal.CarelevoStartTempBasalInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.basal.model.StartTempBasalInfusionRequestModel +import kotlinx.coroutines.runBlocking +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +class CarelevoTempBasalCoordinator @Inject constructor( + private val aapsLogger: AAPSLogger, + private val dateUtil: DateUtil, + private val pumpSync: PumpSync, + private val pumpEnactResultProvider: Provider, + private val carelevoPatch: CarelevoPatch, + private val bleSession: CarelevoBleSession, + private val startTempBasalInfusionUseCase: CarelevoStartTempBasalInfusionUseCase, + private val cancelTempBasalInfusionUseCase: CarelevoCancelTempBasalInfusionUseCase +) { + + private companion object { + + private const val RESULT_SUCCESS = 0 + } + + /** + * Set an absolute temp basal (**delivery-critical**). Discrete `TempBasalCommand.byUnit` (0x23→0x83) + * on the session → on `resultCode==0` reuse the use case's `mode=2` persist → then + * `pumpSync.syncTemporaryBasalWithPumpId`. + */ + fun setTempBasalAbsolute( + absoluteRate: Double, + durationInMinutes: Int, + tbrType: PumpSync.TemporaryBasalType, + serialNumber: String, + onLastDataUpdated: () -> Unit + ): PumpEnactResult { + aapsLogger.info( + LTag.PUMPCOMM, + "setTempBasalAbsolute.start absoluteRate=${absoluteRate.toFloat()} durationInMinutes=${durationInMinutes.toLong()}" + ) + // Fail-fast guards (same style as deliverTreatment): a zero/negative duration or rate would + // otherwise be silently mangled into wire bytes. + require(absoluteRate >= 0.0) { "TBR absolute rate must be >= 0, got $absoluteRate" } + require(durationInMinutes > 0) { "TBR duration must be > 0 min, got $durationInMinutes" } + val result = pumpEnactResultProvider.get() + if (!carelevoPatch.isBluetoothEnabled()) { + aapsLogger.info(LTag.PUMPCOMM, "setTempBasalAbsolute.skip reason=bluetoothDisabled") + return result + } + val address = carelevoPatch.getPatchInfoAddress() + ?: return result.success(false).enacted(false).comment("no patch address") + val hour = durationInMinutes / 60 + val min = durationInMinutes % 60 + return try { + val response = runBlocking { bleSession.runSingle(address, TempBasalCommand.byUnit(absoluteRate, hour, min)) } + val success = response.resultCode == RESULT_SUCCESS + // Pump-is-authoritative: once the patch ACKs, the TBR IS running — it must reach pumpSync + // even if the local persist fails, or basal IOB modeling silently diverges from reality + // for the whole TBR duration. + val persisted = success && startTempBasalInfusionUseCase.persistTempBasalStarted( + StartTempBasalInfusionRequestModel(isUnit = true, speed = absoluteRate, minutes = durationInMinutes) + ) + aapsLogger.info(LTag.PUMPCOMM, "newBle.setTempBasalAbsolute rate=$absoluteRate result=${response.resultCode} persisted=$persisted") + if (success && !persisted) aapsLogger.error(LTag.PUMPCOMM, "newBle.setTempBasalAbsolute persist failed — pump enacted, recording in pumpSync anyway") + if (success) { + onLastDataUpdated() + runBlocking { + pumpSync.syncTemporaryBasalWithPumpId( + timestamp = dateUtil.now(), + rate = PumpRate(absoluteRate), + duration = T.mins(durationInMinutes.toLong()).msecs(), + isAbsolute = true, + type = tbrType, + pumpId = dateUtil.now(), + pumpType = PumpType.CAREMEDI_CARELEVO, + pumpSerial = serialNumber + ) + } + result.success(true).enacted(true) + .duration(durationInMinutes) + .absolute(absoluteRate) + .isPercent(false) + .isTempCancel(false) + } else { + result.success(false).enacted(false).comment("Internal error") + } + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.setTempBasalAbsolute FAILED", e) + result.success(false).enacted(false).comment(e.message ?: "error") + } + } + + /** + * Set a percent temp basal. Discrete `TempBasalCommand.byPercent` (5-byte, value=`percent/100`) on + * the session → `mode=2` persist → `pumpSync`. + */ + fun setTempBasalPercent( + percent: Int, + durationInMinutes: Int, + tbrType: PumpSync.TemporaryBasalType, + serialNumber: String, + onLastDataUpdated: () -> Unit + ): PumpEnactResult { + require(percent >= 0) { "TBR percent must be >= 0, got $percent" } + require(durationInMinutes > 0) { "TBR duration must be > 0 min, got $durationInMinutes" } + val result = pumpEnactResultProvider.get() + aapsLogger.debug(LTag.PUMPCOMM, "setTempBasalPercent.start percent=$percent durationInMinutes=$durationInMinutes") + if (!carelevoPatch.isBluetoothEnabled()) { + aapsLogger.debug(LTag.PUMPCOMM, "setTempBasalPercent.skip reason=bluetoothDisabled") + return result + } + val address = carelevoPatch.getPatchInfoAddress() + ?: return result.success(false).enacted(false).comment("no patch address") + val hour = durationInMinutes / 60 + val min = durationInMinutes % 60 + return try { + val response = runBlocking { bleSession.runSingle(address, TempBasalCommand.byPercent(percent, hour, min)) } + val success = response.resultCode == RESULT_SUCCESS + // Pump-is-authoritative — see setTempBasalAbsolute. + val persisted = success && startTempBasalInfusionUseCase.persistTempBasalStarted( + StartTempBasalInfusionRequestModel(isUnit = false, percent = percent, minutes = durationInMinutes) + ) + aapsLogger.info(LTag.PUMPCOMM, "newBle.setTempBasalPercent percent=$percent result=${response.resultCode} persisted=$persisted") + if (success && !persisted) aapsLogger.error(LTag.PUMPCOMM, "newBle.setTempBasalPercent persist failed — pump enacted, recording in pumpSync anyway") + if (success) { + onLastDataUpdated() + runBlocking { + pumpSync.syncTemporaryBasalWithPumpId( + timestamp = dateUtil.now(), + rate = PumpRate(percent.toDouble()), + duration = T.mins(durationInMinutes.toLong()).msecs(), + isAbsolute = false, + type = tbrType, + pumpId = dateUtil.now(), + pumpType = PumpType.CAREMEDI_CARELEVO, + pumpSerial = serialNumber + ) + } + result.success = true + result.enacted = true + result.duration = durationInMinutes + result.percent = percent + result.isPercent = true + result.isTempCancel = false + result + } else { + result.success(false).enacted(false).comment("Internal error") + } + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.setTempBasalPercent FAILED", e) + result.success(false).enacted(false).comment(e.message ?: "error") + } + } + + /** + * Cancel a running temp basal. Discrete `TempBasalCancelCommand` (0x2D→0x8D) on the session → + * delete + recompute-mode persist → `pumpSync.syncStop…`. The session's own settle provides the + * inter-op spacing. + */ + fun cancelTempBasal( + serialNumber: String, + onLastDataUpdated: () -> Unit + ): PumpEnactResult { + val result = pumpEnactResultProvider.get() + aapsLogger.debug(LTag.PUMPCOMM, "cancelTempBasal.start") + if (!carelevoPatch.isBluetoothEnabled()) { + aapsLogger.debug(LTag.PUMPCOMM, "cancelTempBasal.skip reason=bluetoothDisabled") + return result + } + val address = carelevoPatch.getPatchInfoAddress() + ?: return result.success(false).enacted(false).comment("no patch address") + return try { + val response = runBlocking { bleSession.runSingle(address, TempBasalCancelCommand()) } + val success = response.resultCode == RESULT_SUCCESS + // Pump-is-authoritative — on ACK the TBR IS stopped on the patch; the stop must reach + // pumpSync even if the local persist fails. + val persisted = success && cancelTempBasalInfusionUseCase.persistTempBasalCancelled() + aapsLogger.info(LTag.PUMPCOMM, "newBle.cancelTempBasal result=${response.resultCode} persisted=$persisted") + if (success && !persisted) aapsLogger.error(LTag.PUMPCOMM, "newBle.cancelTempBasal persist failed — pump stopped, recording stop in pumpSync anyway") + if (success) { + onLastDataUpdated() + runBlocking { + pumpSync.syncStopTemporaryBasalWithPumpId( + timestamp = dateUtil.now(), + endPumpId = dateUtil.now(), + pumpType = PumpType.CAREMEDI_CARELEVO, + pumpSerial = serialNumber + ) + } + result.success = true + result.enacted = true + result.isTempCancel = true + result + } else { + result.success = false + result.enacted = false + result + } + } catch (e: Exception) { + aapsLogger.error(LTag.PUMPCOMM, "newBle.cancelTempBasal FAILED", e) + result.success = false + result.enacted = false + result + } + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/common/CarelevoGsonHelper.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/common/CarelevoGsonHelper.kt new file mode 100644 index 000000000000..b902f391fa4e --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/common/CarelevoGsonHelper.kt @@ -0,0 +1,22 @@ +package app.aaps.pump.carelevo.data.common + +import com.google.gson.Gson +import com.google.gson.GsonBuilder + +object CarelevoGsonHelper { + + private var defaultGson: Gson? = null + + init { + defaultGson = GsonBuilder() + .serializeSpecialFloatingPointValues() + .create() + } + + fun sharedGson(): Gson { + if (defaultGson == null) { + throw RuntimeException("Not configure gson") + } + return defaultGson!! + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoAlarmInfoDao.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoAlarmInfoDao.kt new file mode 100644 index 000000000000..715caa5a2a30 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoAlarmInfoDao.kt @@ -0,0 +1,21 @@ +package app.aaps.pump.carelevo.data.dao + +import app.aaps.pump.carelevo.data.model.entities.CarelevoAlarmInfoEntity +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import java.util.Optional + +interface CarelevoAlarmInfoDao { + + fun getAlarms(): Observable>> + + /** One-shot read of ALL stored alarms. Everything stored is active — acknowledging removes. */ + fun getAlarmsOnce(): Single>> + fun setAlarms(list: List): Completable + fun clearAlarms(): Completable + fun upsertAlarm(entity: CarelevoAlarmInfoEntity): Completable + + /** Remove one alarm from the store (= acknowledge; there is no acknowledged-alarm history). */ + fun removeAlarm(alarmId: String): Completable +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoAlarmInfoDaoImpl.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoAlarmInfoDaoImpl.kt new file mode 100644 index 000000000000..0a7338805ced --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoAlarmInfoDaoImpl.kt @@ -0,0 +1,106 @@ +package app.aaps.pump.carelevo.data.dao + +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.pump.carelevo.config.PrefEnvConfig +import app.aaps.pump.carelevo.data.common.CarelevoGsonHelper +import app.aaps.pump.carelevo.data.model.entities.CarelevoAlarmInfoEntity +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.subjects.BehaviorSubject +import jakarta.inject.Inject +import java.util.Optional + +class CarelevoAlarmInfoDaoImpl @Inject constructor( + private val prefManager: SP +) : CarelevoAlarmInfoDao { + + private val _alarms: BehaviorSubject>> = BehaviorSubject.create() + + override fun getAlarms(): Observable>> { + // Cold-load through the SAME unfiltered loader every other method uses. The original + // vendor code filtered this one path to `it.acknowledged` — which is never true, because + // acknowledging DELETES the entity (see removeAlarm) — so every persisted active alarm + // (occlusion, out of insulin, …) silently vanished from the stream on process restart. + if (_alarms.value == null) { + runCatching { ensureLoaded() } + .onFailure { e -> + e.printStackTrace() + _alarms.onNext(Optional.ofNullable(null)) + } + } + return _alarms + } + + override fun getAlarmsOnce(): Single>> { + // Everything in the store is an active (unacknowledged) alarm by construction — an + // acknowledged alarm is removed, not flagged. + return Single.fromCallable { Optional.of(ensureLoaded()) } + } + + override fun setAlarms(list: List): Completable { + return Completable.fromAction { + saveList(list) + _alarms.onNext(Optional.of(list)) + } + } + + override fun clearAlarms(): Completable = Completable.fromAction { + prefManager.remove(PrefEnvConfig.CARELEVO_ALARM_INFO_LIST) + _alarms.onNext(Optional.ofNullable(null)) + } + + override fun upsertAlarm(entity: CarelevoAlarmInfoEntity): Completable { + return Completable.fromAction { + val current = ensureLoaded() + + val idx = current.indexOfFirst { + it.alarmType == entity.alarmType && + it.cause == entity.cause && + !it.acknowledged + } + + val next = if (idx >= 0) { + current.toMutableList().apply { + val existing = this[idx] + this[idx] = existing.copy( + updatedAt = entity.updatedAt, + occurrenceCount = existing.occurrenceCount + 1 + ) + } + } else { + current + entity.copy(occurrenceCount = 1) + } + + saveList(next) + _alarms.onNext(Optional.of(next)) + } + } + + override fun removeAlarm(alarmId: String): Completable { + return Completable.fromAction { + val current = ensureLoaded() + val next = current.filterNot { it.alarmId == alarmId } + + saveList(next) + _alarms.onNext(Optional.of(next)) + } + } + + private fun ensureLoaded(): List { + val cached = _alarms.value?.orElse(null) + if (cached != null) return cached + + val json = prefManager.getString(PrefEnvConfig.CARELEVO_ALARM_INFO_LIST, "") + val list = if (json.isBlank()) emptyList() else CarelevoGsonHelper.sharedGson() + .fromJson(json, Array::class.java) + .toList() + _alarms.onNext(Optional.of(list)) + return list + } + + private fun saveList(list: List) { + val json = CarelevoGsonHelper.sharedGson().toJson(list) + prefManager.putString(PrefEnvConfig.CARELEVO_ALARM_INFO_LIST, json) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoInfusionInfoDao.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoInfusionInfoDao.kt new file mode 100644 index 000000000000..95e6e564e3d0 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoInfusionInfoDao.kt @@ -0,0 +1,26 @@ +package app.aaps.pump.carelevo.data.dao + +import app.aaps.pump.carelevo.data.model.entities.CarelevoBasalInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoExtendBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoImmeBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoTempBasalInfusionInfoEntity +import io.reactivex.rxjava3.core.Observable +import java.util.Optional + +interface CarelevoInfusionInfoDao { + + fun getInfusionInfo(): Observable> + fun getInfusionInfoBySync(): CarelevoInfusionInfoEntity? + + fun updateBasalInfusionInfo(info: CarelevoBasalInfusionInfoEntity): Boolean + fun updateTempBasalInfusionInfo(info: CarelevoTempBasalInfusionInfoEntity): Boolean + fun updateImmeBolusInfusionInfo(info: CarelevoImmeBolusInfusionInfoEntity): Boolean + fun updateExtendBolusInfusionInfo(info: CarelevoExtendBolusInfusionInfoEntity): Boolean + + fun deleteBasalInfusionInfo(): Boolean + fun deleteTempBasalInfusionInfo(): Boolean + fun deleteImmeBolusInfusionInfo(): Boolean + fun deleteExtendBolusInfusionInfo(): Boolean + fun deleteInfusionInfo(): Boolean +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoInfusionInfoDaoImpl.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoInfusionInfoDaoImpl.kt new file mode 100644 index 000000000000..2d913a95b847 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoInfusionInfoDaoImpl.kt @@ -0,0 +1,141 @@ +package app.aaps.pump.carelevo.data.dao + +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.pump.carelevo.config.PrefEnvConfig +import app.aaps.pump.carelevo.data.common.CarelevoGsonHelper +import app.aaps.pump.carelevo.data.model.entities.CarelevoBasalInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoExtendBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoImmeBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoTempBasalInfusionInfoEntity +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.subjects.BehaviorSubject +import java.util.Optional +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +/** + * SharedPreferences-backed store of the four per-mode infusion records (basal / temp-basal / + * immediate bolus / extended bolus), each persisted as Gson under its own key and mirrored into + * one aggregate [CarelevoInfusionInfoEntity] on the [_infusionInfo] BehaviorSubject. + */ +class CarelevoInfusionInfoDaoImpl @Inject constructor( + private val prefManager: SP, +) : CarelevoInfusionInfoDao { + + private val _infusionInfo: BehaviorSubject> = BehaviorSubject.create() + + /** Load one per-mode record from its preference key; null when absent or unparseable. */ + private inline fun loadEntity(key: String): T? = runCatching { + val json = prefManager.getString(key, "") + if (json == "") throw NullPointerException("$key is empty") + CarelevoGsonHelper.sharedGson().fromJson(json, T::class.java) + }.fold( + onSuccess = { it }, + onFailure = { + it.printStackTrace() + null + } + ) + + /** Seed [_infusionInfo] from prefs if this is the first read of the process. */ + private fun ensureLoaded() { + if (_infusionInfo.value != null) return + val infusionInfo = CarelevoInfusionInfoEntity( + basalInfusionInfo = loadEntity(PrefEnvConfig.BASAL_INFUSION_INFO), + tempBasalInfusionInfo = loadEntity(PrefEnvConfig.TEMP_BASAL_INFUSION_INFO), + immeBolusInfusionInfo = loadEntity(PrefEnvConfig.IMME_BOLUS_INFUSION_INFO), + extendBolusInfusionInfo = loadEntity(PrefEnvConfig.EXTEND_BOLUS_INFUSION_INFO) + ).takeUnless { it.basalInfusionInfo == null && it.tempBasalInfusionInfo == null && it.immeBolusInfusionInfo == null && it.extendBolusInfusionInfo == null } + _infusionInfo.onNext(Optional.ofNullable(infusionInfo)) + } + + override fun getInfusionInfo(): Observable> { + ensureLoaded() + return _infusionInfo + } + + override fun getInfusionInfoBySync(): CarelevoInfusionInfoEntity? { + ensureLoaded() + return _infusionInfo.value?.getOrNull() + } + + /** + * Persist one per-mode record and fold it into the aggregate subject. [mutate] applies the new + * record to the current aggregate (creating one if none exists yet). + */ + private fun updateField(key: String, info: Any, mutate: (CarelevoInfusionInfoEntity) -> CarelevoInfusionInfoEntity): Boolean = runCatching { + prefManager.putString(key, CarelevoGsonHelper.sharedGson().toJson(info)) + }.fold( + onSuccess = { + val current = _infusionInfo.value?.getOrNull() ?: CarelevoInfusionInfoEntity() + _infusionInfo.onNext(Optional.of(mutate(current))) + true + }, + onFailure = { + it.printStackTrace() + false + } + ) + + override fun updateBasalInfusionInfo(info: CarelevoBasalInfusionInfoEntity): Boolean = + updateField(PrefEnvConfig.BASAL_INFUSION_INFO, info) { it.copy(basalInfusionInfo = info) } + + override fun updateTempBasalInfusionInfo(info: CarelevoTempBasalInfusionInfoEntity): Boolean = + updateField(PrefEnvConfig.TEMP_BASAL_INFUSION_INFO, info) { it.copy(tempBasalInfusionInfo = info) } + + override fun updateImmeBolusInfusionInfo(info: CarelevoImmeBolusInfusionInfoEntity): Boolean = + updateField(PrefEnvConfig.IMME_BOLUS_INFUSION_INFO, info) { it.copy(immeBolusInfusionInfo = info) } + + override fun updateExtendBolusInfusionInfo(info: CarelevoExtendBolusInfusionInfoEntity): Boolean = + updateField(PrefEnvConfig.EXTEND_BOLUS_INFUSION_INFO, info) { it.copy(extendBolusInfusionInfo = info) } + + /** + * Remove one per-mode record and clear it from the aggregate; the aggregate collapses to + * absent when its last record is removed. + */ + private fun deleteField(key: String, mutate: (CarelevoInfusionInfoEntity) -> CarelevoInfusionInfoEntity): Boolean = runCatching { + prefManager.remove(key) + }.fold( + onSuccess = { + val infusionInfo = _infusionInfo.value?.getOrNull()?.let(mutate) + ?.takeUnless { it.basalInfusionInfo == null && it.tempBasalInfusionInfo == null && it.immeBolusInfusionInfo == null && it.extendBolusInfusionInfo == null } + _infusionInfo.onNext(Optional.ofNullable(infusionInfo)) + true + }, + onFailure = { + it.printStackTrace() + false + } + ) + + override fun deleteBasalInfusionInfo(): Boolean = + deleteField(PrefEnvConfig.BASAL_INFUSION_INFO) { it.copy(basalInfusionInfo = null) } + + override fun deleteTempBasalInfusionInfo(): Boolean = + deleteField(PrefEnvConfig.TEMP_BASAL_INFUSION_INFO) { it.copy(tempBasalInfusionInfo = null) } + + override fun deleteImmeBolusInfusionInfo(): Boolean = + deleteField(PrefEnvConfig.IMME_BOLUS_INFUSION_INFO) { it.copy(immeBolusInfusionInfo = null) } + + override fun deleteExtendBolusInfusionInfo(): Boolean = + deleteField(PrefEnvConfig.EXTEND_BOLUS_INFUSION_INFO) { it.copy(extendBolusInfusionInfo = null) } + + override fun deleteInfusionInfo(): Boolean { + return runCatching { + prefManager.remove(PrefEnvConfig.BASAL_INFUSION_INFO) + prefManager.remove(PrefEnvConfig.TEMP_BASAL_INFUSION_INFO) + prefManager.remove(PrefEnvConfig.IMME_BOLUS_INFUSION_INFO) + prefManager.remove(PrefEnvConfig.EXTEND_BOLUS_INFUSION_INFO) + }.fold( + onSuccess = { + _infusionInfo.onNext(Optional.ofNullable(null)) + true + }, + onFailure = { + it.printStackTrace() + false + } + ) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoPatchInfoDao.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoPatchInfoDao.kt new file mode 100644 index 000000000000..77be32a74eee --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoPatchInfoDao.kt @@ -0,0 +1,14 @@ +package app.aaps.pump.carelevo.data.dao + +import app.aaps.pump.carelevo.data.model.entities.CarelevoPatchInfoEntity +import io.reactivex.rxjava3.core.Observable +import java.util.Optional + +interface CarelevoPatchInfoDao { + + fun getPatchInfo(): Observable> + fun getPatchInfoBySync(): CarelevoPatchInfoEntity? + + fun updatePatchInfo(info: CarelevoPatchInfoEntity): Boolean + fun deletePatchInfo(): Boolean +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoPatchInfoDaoImpl.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoPatchInfoDaoImpl.kt new file mode 100644 index 000000000000..f1aa39569740 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoPatchInfoDaoImpl.kt @@ -0,0 +1,93 @@ +package app.aaps.pump.carelevo.data.dao + +import app.aaps.core.interfaces.sharedPreferences.SP + +import app.aaps.pump.carelevo.config.PrefEnvConfig +import app.aaps.pump.carelevo.data.common.CarelevoGsonHelper +import app.aaps.pump.carelevo.data.model.entities.CarelevoPatchInfoEntity +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.subjects.BehaviorSubject +import java.util.Optional +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +class CarelevoPatchInfoDaoImpl @Inject constructor( + private val prefManager: SP +) : CarelevoPatchInfoDao { + + private val _patchInfo: BehaviorSubject> = BehaviorSubject.create() + + override fun getPatchInfo(): Observable> { + if (_patchInfo.value == null) { + runCatching { + val patchInfoString = prefManager.getString(PrefEnvConfig.PATCH_INFO, "") + if (patchInfoString == "") { + throw NullPointerException("patch info is empty") + } + CarelevoGsonHelper.sharedGson().fromJson(patchInfoString, CarelevoPatchInfoEntity::class.java) + }.fold( + onSuccess = { + _patchInfo.onNext(Optional.ofNullable(it)) + }, + onFailure = { + it.printStackTrace() + _patchInfo.onNext(Optional.ofNullable(null)) + } + ) + } + return _patchInfo + } + + override fun getPatchInfoBySync(): CarelevoPatchInfoEntity? { + if (_patchInfo.value == null) { + runCatching { + val patchInfoString = prefManager.getString(PrefEnvConfig.PATCH_INFO, "") + if (patchInfoString == "") { + throw NullPointerException("patch info is empty") + } + CarelevoGsonHelper.sharedGson().fromJson(patchInfoString, CarelevoPatchInfoEntity::class.java) + }.fold( + onSuccess = { + _patchInfo.onNext(Optional.ofNullable(it)) + }, + onFailure = { + it.printStackTrace() + _patchInfo.onNext(Optional.ofNullable(null)) + } + ) + } + + return _patchInfo.value?.getOrNull() + } + + override fun updatePatchInfo(info: CarelevoPatchInfoEntity): Boolean { + return runCatching { + val patchInfoString = CarelevoGsonHelper.sharedGson().toJson(info) + prefManager.putString(PrefEnvConfig.PATCH_INFO, patchInfoString) + }.fold( + onSuccess = { + _patchInfo.onNext(Optional.ofNullable(info)) + true + }, + onFailure = { + it.printStackTrace() + false + } + ) + } + + override fun deletePatchInfo(): Boolean { + return runCatching { + prefManager.remove(PrefEnvConfig.PATCH_INFO) + }.fold( + onSuccess = { + _patchInfo.onNext(Optional.ofNullable(null)) + true + }, + onFailure = { + it.printStackTrace() + false + } + ) + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoUserSettingInfoDao.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoUserSettingInfoDao.kt new file mode 100644 index 000000000000..c742431c414a --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoUserSettingInfoDao.kt @@ -0,0 +1,14 @@ +package app.aaps.pump.carelevo.data.dao + +import app.aaps.pump.carelevo.data.model.entities.CarelevoUserSettingInfoEntity +import io.reactivex.rxjava3.core.Observable +import java.util.Optional + +interface CarelevoUserSettingInfoDao { + + fun getUserSetting(): Observable> + fun getUserSettingBySync(): CarelevoUserSettingInfoEntity? + + fun updateUserSetting(setting: CarelevoUserSettingInfoEntity): Boolean + fun deleteUserSetting(): Boolean +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoUserSettingInfoDaoImpl.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoUserSettingInfoDaoImpl.kt new file mode 100644 index 000000000000..8ad5896fbcad --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoUserSettingInfoDaoImpl.kt @@ -0,0 +1,92 @@ +package app.aaps.pump.carelevo.data.dao + +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.pump.carelevo.config.PrefEnvConfig +import app.aaps.pump.carelevo.data.common.CarelevoGsonHelper +import app.aaps.pump.carelevo.data.model.entities.CarelevoUserSettingInfoEntity +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.subjects.BehaviorSubject +import java.util.Optional +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +class CarelevoUserSettingInfoDaoImpl @Inject constructor( + private val prefManager: SP +) : CarelevoUserSettingInfoDao { + + private val _userSettingInfo: BehaviorSubject> = BehaviorSubject.create() + + override fun getUserSetting(): Observable> { + if (_userSettingInfo.value == null) { + runCatching { + val userSettingInfoString = prefManager.getString(PrefEnvConfig.USER_SETTING_INFO, "") + if (userSettingInfoString == "") { + throw NullPointerException("user setting info is empty") + } + CarelevoGsonHelper.sharedGson().fromJson(userSettingInfoString, CarelevoUserSettingInfoEntity::class.java) + }.fold( + onSuccess = { + _userSettingInfo.onNext(Optional.ofNullable(it)) + }, + onFailure = { + it.printStackTrace() + _userSettingInfo.onNext(Optional.ofNullable(null)) + } + ) + } + + return _userSettingInfo + } + + override fun getUserSettingBySync(): CarelevoUserSettingInfoEntity? { + if (_userSettingInfo.value == null) { + runCatching { + val userSettingInfoString = prefManager.getString(PrefEnvConfig.USER_SETTING_INFO, "") + if (userSettingInfoString == "") { + throw NullPointerException("user setting info is empty") + } + CarelevoGsonHelper.sharedGson().fromJson(userSettingInfoString, CarelevoUserSettingInfoEntity::class.java) + }.fold( + onSuccess = { + _userSettingInfo.onNext(Optional.ofNullable(it)) + }, + onFailure = { + it.printStackTrace() + _userSettingInfo.onNext(Optional.ofNullable(null)) + } + ) + } + return _userSettingInfo.value?.getOrNull() + } + + override fun updateUserSetting(setting: CarelevoUserSettingInfoEntity): Boolean { + return runCatching { + val userSettingInfoString = CarelevoGsonHelper.sharedGson().toJson(setting) + prefManager.putString(PrefEnvConfig.USER_SETTING_INFO, userSettingInfoString) + }.fold( + onSuccess = { + _userSettingInfo.onNext(Optional.ofNullable(setting)) + true + }, + onFailure = { + it.printStackTrace() + false + } + ) + } + + override fun deleteUserSetting(): Boolean { + return runCatching { + prefManager.remove(PrefEnvConfig.USER_SETTING_INFO) + }.fold( + onSuccess = { + _userSettingInfo.onNext(Optional.ofNullable(null)) + true + }, + onFailure = { + it.printStackTrace() + false + } + ) + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoAlarmInfoLocalDataSource.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoAlarmInfoLocalDataSource.kt new file mode 100644 index 000000000000..c786a93f3bf8 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoAlarmInfoLocalDataSource.kt @@ -0,0 +1,18 @@ +package app.aaps.pump.carelevo.data.dataSource.local + +import app.aaps.pump.carelevo.data.model.entities.CarelevoAlarmInfoEntity +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import java.util.Optional + +interface CarelevoAlarmInfoLocalDataSource { + + fun observeAlarms(): Observable>> + + fun getAlarmsOnce(): Single>> + fun setAlarms(list: List): Completable + fun upsertAlarm(entity: CarelevoAlarmInfoEntity): Completable + fun removeAlarm(alarmId: String): Completable + fun clearAlarms(): Completable +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoAlarmInfoLocalDataSourceImpl.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoAlarmInfoLocalDataSourceImpl.kt new file mode 100644 index 000000000000..fd26274b8009 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoAlarmInfoLocalDataSourceImpl.kt @@ -0,0 +1,31 @@ +package app.aaps.pump.carelevo.data.dataSource.local + +import app.aaps.pump.carelevo.data.dao.CarelevoAlarmInfoDao +import app.aaps.pump.carelevo.data.model.entities.CarelevoAlarmInfoEntity +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import java.util.Optional +import javax.inject.Inject + +class CarelevoAlarmInfoLocalDataSourceImpl @Inject constructor( + private val dao: CarelevoAlarmInfoDao +) : CarelevoAlarmInfoLocalDataSource { + + override fun observeAlarms(): Observable>> = + dao.getAlarms() + + override fun getAlarmsOnce(): Single>> = dao.getAlarmsOnce() + + override fun setAlarms(list: List): Completable = + dao.setAlarms(list) + + override fun upsertAlarm(entity: CarelevoAlarmInfoEntity): Completable = + dao.upsertAlarm(entity) + + override fun removeAlarm(alarmId: String): Completable = + dao.removeAlarm(alarmId) + + override fun clearAlarms(): Completable = + dao.clearAlarms() +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoInfusionInfoDataSource.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoInfusionInfoDataSource.kt new file mode 100644 index 000000000000..13bc3438bf91 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoInfusionInfoDataSource.kt @@ -0,0 +1,26 @@ +package app.aaps.pump.carelevo.data.dataSource.local + +import app.aaps.pump.carelevo.data.model.entities.CarelevoBasalInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoExtendBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoImmeBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoTempBasalInfusionInfoEntity +import io.reactivex.rxjava3.core.Observable +import java.util.Optional + +interface CarelevoInfusionInfoDataSource { + + fun getInfusionInfo(): Observable> + fun getInfusionInfoBySync(): CarelevoInfusionInfoEntity? + + fun updateBasalInfusionInfo(info: CarelevoBasalInfusionInfoEntity): Boolean + fun updateTempBasalInfusionInfo(info: CarelevoTempBasalInfusionInfoEntity): Boolean + fun updateImmeBolusInfusionInfo(info: CarelevoImmeBolusInfusionInfoEntity): Boolean + fun updateExtendBolusInfusionInfo(info: CarelevoExtendBolusInfusionInfoEntity): Boolean + + fun deleteBasalInfusionInfo(): Boolean + fun deleteTempBasalInfusionInfo(): Boolean + fun deleteImmeBolusInfusionInfo(): Boolean + fun deleteExtendBolusInfusionInfo(): Boolean + fun deleteInfusionInfo(): Boolean +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoInfusionInfoDataSourceImpl.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoInfusionInfoDataSourceImpl.kt new file mode 100644 index 000000000000..7b81e9fb4c8d --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoInfusionInfoDataSourceImpl.kt @@ -0,0 +1,60 @@ +package app.aaps.pump.carelevo.data.dataSource.local + +import app.aaps.pump.carelevo.data.dao.CarelevoInfusionInfoDao +import app.aaps.pump.carelevo.data.model.entities.CarelevoBasalInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoExtendBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoImmeBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoTempBasalInfusionInfoEntity +import io.reactivex.rxjava3.core.Observable +import java.util.Optional +import javax.inject.Inject + +class CarelevoInfusionInfoDataSourceImpl @Inject constructor( + private val infusionInfoDao: CarelevoInfusionInfoDao +) : CarelevoInfusionInfoDataSource { + + override fun getInfusionInfo(): Observable> { + return infusionInfoDao.getInfusionInfo() + } + + override fun getInfusionInfoBySync(): CarelevoInfusionInfoEntity? { + return infusionInfoDao.getInfusionInfoBySync() + } + + override fun updateBasalInfusionInfo(info: CarelevoBasalInfusionInfoEntity): Boolean { + return infusionInfoDao.updateBasalInfusionInfo(info) + } + + override fun updateTempBasalInfusionInfo(info: CarelevoTempBasalInfusionInfoEntity): Boolean { + return infusionInfoDao.updateTempBasalInfusionInfo(info) + } + + override fun updateImmeBolusInfusionInfo(info: CarelevoImmeBolusInfusionInfoEntity): Boolean { + return infusionInfoDao.updateImmeBolusInfusionInfo(info) + } + + override fun updateExtendBolusInfusionInfo(info: CarelevoExtendBolusInfusionInfoEntity): Boolean { + return infusionInfoDao.updateExtendBolusInfusionInfo(info) + } + + override fun deleteBasalInfusionInfo(): Boolean { + return infusionInfoDao.deleteBasalInfusionInfo() + } + + override fun deleteTempBasalInfusionInfo(): Boolean { + return infusionInfoDao.deleteTempBasalInfusionInfo() + } + + override fun deleteImmeBolusInfusionInfo(): Boolean { + return infusionInfoDao.deleteImmeBolusInfusionInfo() + } + + override fun deleteExtendBolusInfusionInfo(): Boolean { + return infusionInfoDao.deleteExtendBolusInfusionInfo() + } + + override fun deleteInfusionInfo(): Boolean { + return infusionInfoDao.deleteInfusionInfo() + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoPatchInfoDataSource.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoPatchInfoDataSource.kt new file mode 100644 index 000000000000..698456c134cc --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoPatchInfoDataSource.kt @@ -0,0 +1,14 @@ +package app.aaps.pump.carelevo.data.dataSource.local + +import app.aaps.pump.carelevo.data.model.entities.CarelevoPatchInfoEntity +import io.reactivex.rxjava3.core.Observable +import java.util.Optional + +interface CarelevoPatchInfoDataSource { + + fun getPatchInfo(): Observable> + fun getPatchInfoBySync(): CarelevoPatchInfoEntity? + + fun updatePatchInfo(info: CarelevoPatchInfoEntity): Boolean + fun deletePatchInfo(): Boolean +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoPatchInfoDataSourceImpl.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoPatchInfoDataSourceImpl.kt new file mode 100644 index 000000000000..929123fdd7eb --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoPatchInfoDataSourceImpl.kt @@ -0,0 +1,28 @@ +package app.aaps.pump.carelevo.data.dataSource.local + +import app.aaps.pump.carelevo.data.dao.CarelevoPatchInfoDao +import app.aaps.pump.carelevo.data.model.entities.CarelevoPatchInfoEntity +import io.reactivex.rxjava3.core.Observable +import java.util.Optional +import javax.inject.Inject + +class CarelevoPatchInfoDataSourceImpl @Inject constructor( + private val patchInfoDao: CarelevoPatchInfoDao +) : CarelevoPatchInfoDataSource { + + override fun getPatchInfo(): Observable> { + return patchInfoDao.getPatchInfo() + } + + override fun getPatchInfoBySync(): CarelevoPatchInfoEntity? { + return patchInfoDao.getPatchInfoBySync() + } + + override fun updatePatchInfo(info: CarelevoPatchInfoEntity): Boolean { + return patchInfoDao.updatePatchInfo(info) + } + + override fun deletePatchInfo(): Boolean { + return patchInfoDao.deletePatchInfo() + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoUserSettingInfoDataSource.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoUserSettingInfoDataSource.kt new file mode 100644 index 000000000000..9555f6df6c40 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoUserSettingInfoDataSource.kt @@ -0,0 +1,14 @@ +package app.aaps.pump.carelevo.data.dataSource.local + +import app.aaps.pump.carelevo.data.model.entities.CarelevoUserSettingInfoEntity +import io.reactivex.rxjava3.core.Observable +import java.util.Optional + +interface CarelevoUserSettingInfoDataSource { + + fun getUserSettingInfo(): Observable> + fun getUserSettingInfoBySync(): CarelevoUserSettingInfoEntity? + + fun updateUserSettingInfo(info: CarelevoUserSettingInfoEntity): Boolean + fun deleteUserSettingInfo(): Boolean +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoUserSettingInfoDataSourceImpl.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoUserSettingInfoDataSourceImpl.kt new file mode 100644 index 000000000000..ebff52865250 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoUserSettingInfoDataSourceImpl.kt @@ -0,0 +1,28 @@ +package app.aaps.pump.carelevo.data.dataSource.local + +import app.aaps.pump.carelevo.data.dao.CarelevoUserSettingInfoDao +import app.aaps.pump.carelevo.data.model.entities.CarelevoUserSettingInfoEntity +import io.reactivex.rxjava3.core.Observable +import java.util.Optional +import javax.inject.Inject + +class CarelevoUserSettingInfoDataSourceImpl @Inject constructor( + private val userSettingInfoDao: CarelevoUserSettingInfoDao +) : CarelevoUserSettingInfoDataSource { + + override fun getUserSettingInfo(): Observable> { + return userSettingInfoDao.getUserSetting() + } + + override fun getUserSettingInfoBySync(): CarelevoUserSettingInfoEntity? { + return userSettingInfoDao.getUserSettingBySync() + } + + override fun updateUserSettingInfo(info: CarelevoUserSettingInfoEntity): Boolean { + return userSettingInfoDao.updateUserSetting(info) + } + + override fun deleteUserSettingInfo(): Boolean { + return userSettingInfoDao.deleteUserSetting() + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoAlarmInfoMapper.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoAlarmInfoMapper.kt new file mode 100644 index 000000000000..28fa2e54f512 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoAlarmInfoMapper.kt @@ -0,0 +1,28 @@ +package app.aaps.pump.carelevo.data.mapper + +import app.aaps.pump.carelevo.data.model.entities.CarelevoAlarmInfoEntity +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.type.AlarmType + +fun CarelevoAlarmInfoEntity.transformToDomainModel(): CarelevoAlarmInfo = + CarelevoAlarmInfo( + alarmId = alarmId, + alarmType = AlarmType.fromCode(alarmType), + cause = cause, + value = value, + createdAt = createdAt, + updatedAt = updatedAt, + isAcknowledged = acknowledged, + occurrenceCount = occurrenceCount + ) + +fun CarelevoAlarmInfo.transformToEntity(): CarelevoAlarmInfoEntity = + CarelevoAlarmInfoEntity( + alarmId = alarmId, + alarmType = AlarmType.fromAlarmType(alarmType), + cause = cause, + value = value, + createdAt = createdAt, + updatedAt = updatedAt, + acknowledged = isAcknowledged, + ) \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoBtMapper.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoBtMapper.kt new file mode 100644 index 000000000000..7e28bf7cfa96 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoBtMapper.kt @@ -0,0 +1,401 @@ +package app.aaps.pump.carelevo.data.mapper + +import app.aaps.pump.carelevo.data.model.ble.ProtocolAdditionalBasalInfusionChangeRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolAdditionalBasalProgramSetRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolAdditionalPrimingRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolAlertMsgRptModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolAppAuthAckRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolAppAuthKeyAckRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolAppStatusRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolBasalInfusionChangeRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolBasalInfusionResumeRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolBasalInfusionStartRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolBasalProgramSetRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolBolusInfusionCancelRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolBuzzUsageChangeRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolCannulaInsertionAckRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolCannulaInsertionStatusRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolExtendBolusDelayRptModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolExtendBolusInfusionCancelRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolExtendBolusInfusionRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolImmeBolusInfusionRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolInfusionStatusInquiryRptModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolInfusionThresholdRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolMsgSolutionRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolNoticeMsgRptModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolNoticeThresholdRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchAddressRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchAlertAlarmSetRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchBuzzInspectionRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchDiscardRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchExpiryExtendRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchInformationInquiryDetailRptModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchInformationInquiryRptModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchInitRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchOperationDataRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchRecoveryRptModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchThresholdSetRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPumpResumeRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPumpStopRptModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPumpStopRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolSafetyCheckRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolSetTimeRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolTempBasalInfusionCancelRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolTempBasalInfusionRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolWarningMsgRptModel +import app.aaps.pump.carelevo.domain.model.bt.AdditionalPrimingResponse +import app.aaps.pump.carelevo.domain.model.bt.AlertReportResponse +import app.aaps.pump.carelevo.domain.model.bt.AppAuthAckRptResponse +import app.aaps.pump.carelevo.domain.model.bt.AppAuthRptResponse +import app.aaps.pump.carelevo.domain.model.bt.CancelExtendBolusResponse +import app.aaps.pump.carelevo.domain.model.bt.CancelImmeBolusResponse +import app.aaps.pump.carelevo.domain.model.bt.CancelTempBasalProgramResponse +import app.aaps.pump.carelevo.domain.model.bt.CannulaInsertionAckResponse +import app.aaps.pump.carelevo.domain.model.bt.CannulaInsertionResponse +import app.aaps.pump.carelevo.domain.model.bt.CheckBuzzResponse +import app.aaps.pump.carelevo.domain.model.bt.ClearReportResponse +import app.aaps.pump.carelevo.domain.model.bt.DelayExtendBolusResponse +import app.aaps.pump.carelevo.domain.model.bt.NoticeReportResponse +import app.aaps.pump.carelevo.domain.model.bt.PatchInformationInquiryDetailResponse +import app.aaps.pump.carelevo.domain.model.bt.PatchInformationInquiryResponse +import app.aaps.pump.carelevo.domain.model.bt.RecoveryPatchResponse +import app.aaps.pump.carelevo.domain.model.bt.ResumeBasalProgramResponse +import app.aaps.pump.carelevo.domain.model.bt.ResumePumpResponse +import app.aaps.pump.carelevo.domain.model.bt.RetrieveAddressResponse +import app.aaps.pump.carelevo.domain.model.bt.RetrieveInfusionStatusResponse +import app.aaps.pump.carelevo.domain.model.bt.RetrieveOperationInfoResponse +import app.aaps.pump.carelevo.domain.model.bt.SafetyCheckResponse +import app.aaps.pump.carelevo.domain.model.bt.SetAlertAlarmModelResponse +import app.aaps.pump.carelevo.domain.model.bt.SetApplicationStatusResponse +import app.aaps.pump.carelevo.domain.model.bt.SetBasalProgramAdditionalResponse +import app.aaps.pump.carelevo.domain.model.bt.SetBasalProgramResponse +import app.aaps.pump.carelevo.domain.model.bt.SetBuzzModeResponse +import app.aaps.pump.carelevo.domain.model.bt.SetDiscardResponse +import app.aaps.pump.carelevo.domain.model.bt.SetExpiryExtendResponse +import app.aaps.pump.carelevo.domain.model.bt.SetInfusionThresholdResponse +import app.aaps.pump.carelevo.domain.model.bt.SetInitializeResponse +import app.aaps.pump.carelevo.domain.model.bt.SetThresholdNoticeResponse +import app.aaps.pump.carelevo.domain.model.bt.SetTimeResponse +import app.aaps.pump.carelevo.domain.model.bt.StartBasalProgramResponse +import app.aaps.pump.carelevo.domain.model.bt.StartExtendBolusResponse +import app.aaps.pump.carelevo.domain.model.bt.StartImmeBolusResponse +import app.aaps.pump.carelevo.domain.model.bt.StartTempBasalProgramResponse +import app.aaps.pump.carelevo.domain.model.bt.StopPumpReportResponse +import app.aaps.pump.carelevo.domain.model.bt.StopPumpResponse +import app.aaps.pump.carelevo.domain.model.bt.ThresholdSetResponse +import app.aaps.pump.carelevo.domain.model.bt.UpdateBasalProgramAdditionalResponse +import app.aaps.pump.carelevo.domain.model.bt.UpdateBasalProgramResponse +import app.aaps.pump.carelevo.domain.model.bt.WarningReportResponse + +internal fun ProtocolSetTimeRspModel.transformToDomainModel() = SetTimeResponse( + timestamp = timestamp, + command = command, + result = result +) + +internal fun ProtocolSafetyCheckRspModel.transformToDomainModel() = SafetyCheckResponse( + timestamp = timestamp, + command = command, + result = result, + volume = insulinVolume, + durationSeconds = durationSeconds +) + +internal fun ProtocolAdditionalPrimingRspModel.transformToDomainModel() = AdditionalPrimingResponse( + timestamp = timestamp, + command = command, + result = result +) + +internal fun ProtocolPatchAlertAlarmSetRspModel.transformToDomainModel() = SetAlertAlarmModelResponse( + timestamp = timestamp, + command = command, + result = result +) + +internal fun ProtocolNoticeThresholdRspModel.transformToDomainModel() = SetThresholdNoticeResponse( + timestamp = timestamp, + command = command, + result = result, + type = type +) + +internal fun ProtocolInfusionThresholdRspModel.transformToDomainModel() = SetInfusionThresholdResponse( + timestamp = timestamp, + command = command, + result = result, + type = type +) + +internal fun ProtocolBuzzUsageChangeRspModel.transformToDomainModel() = SetBuzzModeResponse( + timestamp = timestamp, + command = command, + result = result +) + +internal fun ProtocolCannulaInsertionStatusRspModel.transformToDomainModel() = CannulaInsertionResponse( + timestamp = timestamp, + command = command, + result = result +) + +internal fun ProtocolCannulaInsertionAckRspModel.transformToDomainModel() = CannulaInsertionAckResponse( + timestamp = timestamp, + command = command, + result = result +) + +internal fun ProtocolPatchThresholdSetRspModel.transformToDomainModel() = ThresholdSetResponse( + timestamp = timestamp, + command = command, + result = result +) + +internal fun ProtocolPatchExpiryExtendRspModel.transformToDomainModel() = SetExpiryExtendResponse( + timestamp = timestamp, + command = command, + result = result +) + +internal fun ProtocolPatchInformationInquiryRptModel.transformToDomainModel() = PatchInformationInquiryResponse( + timestamp = timestamp, + command = command, + result = result, + // productCL = productCL, + // productTY = productTY, + // productMO = productMO, + // processCO = processCO, + // manufactureYE = manufactureYE, + // manufactureMO = manufactureMO, + // manufactureDA = manufactureDA, + // manufactureLO = manufactureLO, + // manufactureNO = manufactureNO + serialNum = serialNum +) + +internal fun ProtocolPatchInformationInquiryDetailRptModel.transformToDomainModel() = PatchInformationInquiryDetailResponse( + timestamp = timestamp, + command = command, + result = result, + firmVersion = firmVersion, + bootDateTime = bootDateTime, + modelName = modelName +) + +internal fun ProtocolAppStatusRspModel.transformToDomainModel() = SetApplicationStatusResponse( + timestamp = timestamp, + command = command, + status = status +) + +internal fun ProtocolInfusionStatusInquiryRptModel.transformToDomainModel() = RetrieveInfusionStatusResponse( + timestamp = timestamp, + command = command, + subId = subId, + runningMinutes = patchRunningTime, + remains = insulinRemains, + infusedTotalBasalAmount = infusedTotalBasalAmount, + infusedTotalBolusAmount = infusedTotalBolusAmount, + pumpState = pumpState, + mode = mode, + infusedSetMinutes = infusedSetMin, + currentInfusedProgramVolume = currentInfusedProgramVolume, + realInfusedTime = realInfusedTime +) + +internal fun ProtocolPumpStopRspModel.transformToDomainModel() = StopPumpResponse( + timestamp = timestamp, + command = command, + result = result +) + +internal fun ProtocolPumpStopRptModel.transformToDomainModel() = StopPumpReportResponse( + timestamp = timestamp, + command = command, + result = result, + mode = mode, + causeId = subId, + infusedBolusAmount = completedBolusInfusionVolume, + unInfusedExtendBolusAmount = unInfusedExtendBolusVolume, + temperature = temperature +) + +internal fun ProtocolPumpResumeRspModel.transformToDomainModel() = ResumePumpResponse( + timestamp = timestamp, + command = command, + result = result, + mode = mode, + causeId = subId +) + +internal fun ProtocolPatchInitRspModel.transformToDomainModel() = SetInitializeResponse( + timestamp = timestamp, + command = command, + mode = mode +) + +internal fun ProtocolPatchDiscardRspModel.transformToDomainModel() = SetDiscardResponse( + timestamp = timestamp, + command = command, + result = result +) + +internal fun ProtocolPatchBuzzInspectionRspModel.transformToDomainModel() = CheckBuzzResponse( + timestamp = timestamp, + command = command, + result = result +) + +internal fun ProtocolPatchOperationDataRspModel.transformToDomainModel() = RetrieveOperationInfoResponse( + timestamp = timestamp, + command = command, + mode = mode, + pulseCnt = pulseCnt, + totalNo = totalNo, + count = count, + useMinutes = useMin, + remains = remains +) + +internal fun ProtocolPatchAddressRspModel.transformToDomainModel() = RetrieveAddressResponse( + timestamp = timestamp, + command = command, + // value = value, + address = macAddress, + checkSum = checkSum +) + +internal fun ProtocolWarningMsgRptModel.transformToDomainModel() = WarningReportResponse( + timestamp = timestamp, + command = command, + cause = cause, + value = value +) + +internal fun ProtocolAlertMsgRptModel.transformToDomainModel() = AlertReportResponse( + timestamp = timestamp, + command = command, + cause = cause, + value = value +) + +internal fun ProtocolNoticeMsgRptModel.transformToDomainModel() = NoticeReportResponse( + timestamp = timestamp, + command = command, + cause = cause, + value = value +) + +internal fun ProtocolMsgSolutionRspModel.transformToDomainModel() = ClearReportResponse( + timestamp = timestamp, + command = command, + result = result, + subId = subId, + cause = cause +) + +internal fun ProtocolBasalProgramSetRspModel.transformToDomainModel() = SetBasalProgramResponse( + timestamp = timestamp, + command = command, + result = result +) + +internal fun ProtocolAdditionalBasalProgramSetRspModel.transformToDomainModel() = SetBasalProgramAdditionalResponse( + timestamp = timestamp, + command = command, + result = result +) + +internal fun ProtocolBasalInfusionChangeRspModel.transformToDomainModel() = UpdateBasalProgramResponse( + timestamp = timestamp, + command = command, + result = result +) + +internal fun ProtocolAdditionalBasalInfusionChangeRspModel.transformToDomainModel() = UpdateBasalProgramAdditionalResponse( + timestamp = timestamp, + command = command, + result = result +) + +internal fun ProtocolTempBasalInfusionRspModel.transformToDomainModel() = StartTempBasalProgramResponse( + timestamp = timestamp, + command = command, + result = result +) + +internal fun ProtocolTempBasalInfusionCancelRspModel.transformToDomainModel() = CancelTempBasalProgramResponse( + timestamp = timestamp, + command = command, + result = result +) + +internal fun ProtocolBasalInfusionStartRspModel.transformToDomainModel() = StartBasalProgramResponse( + timestamp = timestamp, + command = command +) + +internal fun ProtocolBasalInfusionResumeRspModel.transformToDomainModel() = ResumeBasalProgramResponse( + timestamp = timestamp, + command = command, + segmentNo = segmentNo, + infusionSpeed = infusionSpeed, + infusionPeriod = infusionPeriod, + insulinRemains = insulinRemains +) + +internal fun ProtocolImmeBolusInfusionRspModel.transformToDomainModel() = StartImmeBolusResponse( + timestamp = timestamp, + command = command, + result = result, + actionId = actionId, + expectedTime = expectedTime, + remain = remains +) + +internal fun ProtocolBolusInfusionCancelRspModel.transformToDomainModel() = CancelImmeBolusResponse( + timestamp = timestamp, + command = command, + result = result, + remains = insulinRemains, + infusedAmount = infusedAmount +) + +internal fun ProtocolExtendBolusInfusionRspModel.transformToDomainModel() = StartExtendBolusResponse( + timestamp = timestamp, + command = command, + result = result, + expectedTime = expectedTime +) + +internal fun ProtocolExtendBolusInfusionCancelRspModel.transformToDomainModel() = CancelExtendBolusResponse( + timestamp = timestamp, + command = command, + result = result, + infusedAmount = infusedAmount +) + +internal fun ProtocolExtendBolusDelayRptModel.transformToDomainModel() = DelayExtendBolusResponse( + timestamp = timestamp, + command = command, + delayedAmount = delayedAmount, + expectedTime = expectedTime +) + +internal fun ProtocolPatchRecoveryRptModel.transformToDomainModel() = RecoveryPatchResponse( + timestamp = timestamp, + command = command +) + +internal fun ProtocolAppAuthKeyAckRspModel.transformToDomainModel() = AppAuthRptResponse( + timestamp = timestamp, + command = command, + value = value +) + +internal fun ProtocolAppAuthAckRspModel.transformToDomainModel() = AppAuthAckRptResponse( + timestamp = timestamp, + command = command, + result = result +) + diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoInfusionInfoMapper.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoInfusionInfoMapper.kt new file mode 100644 index 000000000000..a1b8038dfe7b --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoInfusionInfoMapper.kt @@ -0,0 +1,129 @@ +package app.aaps.pump.carelevo.data.mapper + +import app.aaps.pump.carelevo.data.model.entities.CarelevoBasalInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoBasalSegmentInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoExtendBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoImmeBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoTempBasalInfusionInfoEntity +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoBasalSegmentInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoExtendBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoImmeBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoTempBasalInfusionInfoDomainModel +import org.joda.time.DateTime + +internal fun CarelevoBasalSegmentInfusionInfoEntity.transformToCarelevoBasalSegmentInfusionInfoDomainModel() = CarelevoBasalSegmentInfusionInfoDomainModel( + createdAt = DateTime.parse(createdAt), + updatedAt = DateTime.parse(updatedAt), + startTime = startTime, + endTime = endTime, + speed = speed +) + +internal fun CarelevoBasalSegmentInfusionInfoDomainModel.transformToCarelevoBasalSegmentInfusionInfoEntity() = CarelevoBasalSegmentInfusionInfoEntity( + createdAt = createdAt.toString(), + updatedAt = updatedAt.toString(), + startTime = startTime, + endTime = endTime, + speed = speed +) + +internal fun CarelevoBasalInfusionInfoEntity.transformToCarelevoBasalInfusionInfoDomainModel() = CarelevoBasalInfusionInfoDomainModel( + infusionId = infusionId, + address = address, + mode = mode, + createdAt = DateTime.parse(createdAt), + updatedAt = DateTime.parse(updatedAt), + segments = segments.map { it.transformToCarelevoBasalSegmentInfusionInfoDomainModel() }, + isStop = isStop +) + +internal fun CarelevoBasalInfusionInfoDomainModel.transformToCarelevoBasalInfusionInfoEntity() = CarelevoBasalInfusionInfoEntity( + infusionId = infusionId, + address = address, + mode = mode, + createdAt = createdAt.toString(), + updatedAt = updatedAt.toString(), + segments = segments.map { it.transformToCarelevoBasalSegmentInfusionInfoEntity() }, + isStop = isStop +) + +internal fun CarelevoTempBasalInfusionInfoEntity.transformToCarelevoTempBasalInfusionInfoDomainModel() = CarelevoTempBasalInfusionInfoDomainModel( + infusionId = infusionId, + address = address, + mode = mode, + createdAt = DateTime.parse(createdAt), + updatedAt = DateTime.parse(updatedAt), + percent = percent, + speed = speed, + infusionDurationMin = infusionDurationMin +) + +internal fun CarelevoTempBasalInfusionInfoDomainModel.transformToCarelevoTempBasalInfusionInfoEntity() = CarelevoTempBasalInfusionInfoEntity( + infusionId = infusionId, + address = address, + mode = mode, + createdAt = createdAt.toString(), + updatedAt = updatedAt.toString(), + percent = percent, + speed = speed, + infusionDurationMin = infusionDurationMin +) + +internal fun CarelevoImmeBolusInfusionInfoEntity.transformToCarelevoImmeBolusInfusionInfoDomainModel() = CarelevoImmeBolusInfusionInfoDomainModel( + infusionId = infusionId, + address = address, + mode = mode, + createdAt = DateTime.parse(createdAt), + updatedAt = DateTime.parse(updatedAt), + volume = volume, + infusionDurationSeconds = infusionDurationSeconds +) + +internal fun CarelevoImmeBolusInfusionInfoDomainModel.transformToCarelevoImmeBolusInfusionInfoEntity() = CarelevoImmeBolusInfusionInfoEntity( + infusionId = infusionId, + address = address, + mode = mode, + createdAt = createdAt.toString(), + updatedAt = updatedAt.toString(), + volume = volume, + infusionDurationSeconds = infusionDurationSeconds +) + +internal fun CarelevoExtendBolusInfusionInfoEntity.transformToCarelevoExtendBolusInfusionInfoDomainModel() = CarelevoExtendBolusInfusionInfoDomainModel( + infusionId = infusionId, + address = address, + mode = mode, + createdAt = DateTime.parse(createdAt), + updatedAt = DateTime.parse(updatedAt), + volume = volume, + speed = speed, + infusionDurationMin = infusionDurationMin +) + +internal fun CarelevoExtendBolusInfusionInfoDomainModel.transformToCarelevoExtendBolusInfusionInfoEntity() = CarelevoExtendBolusInfusionInfoEntity( + infusionId = infusionId, + address = address, + mode = mode, + createdAt = createdAt.toString(), + updatedAt = updatedAt.toString(), + volume = volume, + speed = speed, + infusionDurationMin = infusionDurationMin +) + +internal fun CarelevoInfusionInfoEntity.transformToCarelevoInfusionInfoDomainModel() = CarelevoInfusionInfoDomainModel( + basalInfusionInfo = basalInfusionInfo?.transformToCarelevoBasalInfusionInfoDomainModel(), + tempBasalInfusionInfo = tempBasalInfusionInfo?.transformToCarelevoTempBasalInfusionInfoDomainModel(), + immeBolusInfusionInfo = immeBolusInfusionInfo?.transformToCarelevoImmeBolusInfusionInfoDomainModel(), + extendBolusInfusionInfo = extendBolusInfusionInfo?.transformToCarelevoExtendBolusInfusionInfoDomainModel() +) + +internal fun CarelevoInfusionInfoDomainModel.transformToCarelevoInfusionInfoEntity() = CarelevoInfusionInfoEntity( + basalInfusionInfo = basalInfusionInfo?.transformToCarelevoBasalInfusionInfoEntity(), + tempBasalInfusionInfo = tempBasalInfusionInfo?.transformToCarelevoTempBasalInfusionInfoEntity(), + immeBolusInfusionInfo = immeBolusInfusionInfo?.transformToCarelevoImmeBolusInfusionInfoEntity(), + extendBolusInfusionInfo = extendBolusInfusionInfo?.transformToCarelevoExtendBolusInfusionInfoEntity() +) \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoPatchInfoMapper.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoPatchInfoMapper.kt new file mode 100644 index 000000000000..fcdbe2807dbf --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoPatchInfoMapper.kt @@ -0,0 +1,75 @@ +package app.aaps.pump.carelevo.data.mapper + +import app.aaps.pump.carelevo.data.model.entities.CarelevoPatchInfoEntity +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import org.joda.time.DateTime + +internal fun CarelevoPatchInfoEntity.transformToCarelevoPatchInfoDomainModel() = CarelevoPatchInfoDomainModel( + address = address, + createdAt = DateTime.parse(createdAt), + updatedAt = DateTime.parse(updatedAt), + manufactureNumber = manufactureNumber, + firmwareVersion = firmwareVersion, + bootDateTime = bootDateTime, + bootDateTimeUtcMillis = bootDateTimeUtcMillis, + modelName = modelName, + insulinAmount = insulinAmount, + insulinRemain = insulinRemain, + thresholdInsulinRemain = thresholdInsulinRemain, + thresholdExpiry = thresholdExpiry, + thresholdMaxBasalSpeed = thresholdMaxBasalSpeed, + thresholdMaxBolusDose = thresholdMaxBolusDose, + checkSafety = checkSafety, + checkNeedle = checkNeedle, + needleFailedCount = needleFailedCount, + isConnected = isConnected, + needDiscard = needDiscard, + isDiscard = isDiscard, + isExtended = isExtended, + isValid = isValid, + isStopped = isStopped, + stopMinutes = stopMinutes, + stopMode = stopMode, + isForceStopped = isForceStopped, + runningMinutes = runningMinutes, + infusedTotalBasalAmount = infusedTotalBasalAmount, + infusedTotalBolusAmount = infusedTotalBolusAmount, + pumpState = pumpState, + mode = mode, + bolusActionSeq = bolusActionSeq +) + +internal fun CarelevoPatchInfoDomainModel.transformToCarelevoPatchInfoEntity() = CarelevoPatchInfoEntity( + address = address, + createdAt = createdAt.toString(), + updatedAt = updatedAt.toString(), + manufactureNumber = manufactureNumber, + firmwareVersion = firmwareVersion, + bootDateTime = bootDateTime, + bootDateTimeUtcMillis = bootDateTimeUtcMillis, + modelName = modelName, + insulinAmount = insulinAmount, + insulinRemain = insulinRemain, + thresholdInsulinRemain = thresholdInsulinRemain, + thresholdExpiry = thresholdExpiry, + thresholdMaxBasalSpeed = thresholdMaxBasalSpeed, + thresholdMaxBolusDose = thresholdMaxBolusDose, + checkSafety = checkSafety, + checkNeedle = checkNeedle, + needleFailedCount = needleFailedCount, + isConnected = isConnected, + needDiscard = needDiscard, + isDiscard = isDiscard, + isExtended = isExtended, + isValid = isValid, + isStopped = isStopped, + stopMinutes = stopMinutes, + stopMode = stopMode, + isForceStopped = isForceStopped, + runningMinutes = runningMinutes, + infusedTotalBasalAmount = infusedTotalBasalAmount, + infusedTotalBolusAmount = infusedTotalBolusAmount, + pumpState = pumpState, + mode = mode, + bolusActionSeq = bolusActionSeq +) diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoUserSettingInfoMapper.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoUserSettingInfoMapper.kt new file mode 100644 index 000000000000..601d9d662e3b --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoUserSettingInfoMapper.kt @@ -0,0 +1,27 @@ +package app.aaps.pump.carelevo.data.mapper + +import app.aaps.pump.carelevo.data.model.entities.CarelevoUserSettingInfoEntity +import app.aaps.pump.carelevo.domain.model.userSetting.CarelevoUserSettingInfoDomainModel +import org.joda.time.DateTime + +internal fun CarelevoUserSettingInfoEntity.transformToCarelevoUserSettingInfoDomainModel() = CarelevoUserSettingInfoDomainModel( + createdAt = DateTime.parse(createdAt), + updatedAt = DateTime.parse(updatedAt), + lowInsulinNoticeAmount = lowInsulinNoticeAmount, + maxBasalSpeed = maxBasalSpeed, + maxBolusDose = maxBolusDose, + needLowInsulinNoticeAmountSyncPatch = needLowInsulinNoticeAmountSyncPatch, + needMaxBasalSpeedSyncPatch = needMaxBasalSpeedSyncPatch, + needMaxBolusDoseSyncPatch = needMaxBolusDoseSyncPatch +) + +internal fun CarelevoUserSettingInfoDomainModel.transformToCarelevoUserSettingInfoEntity() = CarelevoUserSettingInfoEntity( + createdAt = createdAt.toString(), + updatedAt = updatedAt.toString(), + lowInsulinNoticeAmount = lowInsulinNoticeAmount, + maxBasalSpeed = maxBasalSpeed, + maxBolusDose = maxBolusDose, + needLowInsulinNoticeAmountSyncPatch = needLowInsulinNoticeAmountSyncPatch, + needMaxBasalSpeedSyncPatch = needMaxBasalSpeedSyncPatch, + needMaxBolusDoseSyncPatch = needMaxBolusDoseSyncPatch +) \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/model/ble/CarelevoProtocolBtModels.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/model/ble/CarelevoProtocolBtModels.kt new file mode 100644 index 000000000000..28cbcc4c7307 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/model/ble/CarelevoProtocolBtModels.kt @@ -0,0 +1,345 @@ +package app.aaps.pump.carelevo.data.model.ble + +internal interface ProtocolRequestModel + +data class ProtocolSegmentModel( + val injectHour: Int, + val injectMin: Int, + val injectSpeed: Double +) : ProtocolRequestModel + +interface ProtocolRspModel { + + val timestamp: Long + val command: Int +} + +data class ProtocolSetTimeRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolAppAuthKeyAckRspModel( + override val timestamp: Long, + override val command: Int, + val value: Int +) : ProtocolRspModel + +data class ProtocolAppAuthAckRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolSafetyCheckRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int, + val insulinVolume: Int, + val durationSeconds: Int +) : ProtocolRspModel + +data class ProtocolInfusionThresholdRspModel( + override val timestamp: Long, + override val command: Int, + val type: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolBuzzUsageChangeRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolCannulaInsertionStatusRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolCannulaInsertionAckRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolPatchThresholdSetRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolPatchAlertAlarmSetRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolNoticeThresholdRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int, + val type: Int +) : ProtocolRspModel + +data class ProtocolPatchExpiryExtendRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolPumpStopRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolPumpResumeRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int, + val mode: Int, + val subId: Int +) : ProtocolRspModel + +data class ProtocolPumpStopRptModel( + override val timestamp: Long, + override val command: Int, + val result: Int, + val cause: Int, + val mode: Int, + val subId: Int, + val completedBolusInfusionVolume: Double, + val unInfusedExtendBolusVolume: Double, + val temperature: Int +) : ProtocolRspModel + +data class ProtocolInfusionStatusInquiryRptModel( + override val timestamp: Long, + override val command: Int, + val subId: Int, + val patchRunningTime: Int, + val insulinRemains: Double, + val infusedTotalBasalAmount: Double, + val infusedTotalBolusAmount: Double, + val pumpState: Int, + val mode: Int, + val infusedSetMin: Int, + val currentInfusedProgramVolume: Double, + val realInfusedTime: Int +) : ProtocolRspModel + +data class ProtocolPatchInformationInquiryRptModel( + override val timestamp: Long, + override val command: Int, + val result: Int, + val serialNum: String +) : ProtocolRspModel + +data class ProtocolPatchInformationInquiryDetailRptModel( + override val timestamp: Long, + override val command: Int, + val result: Int, + val firmVersion: String, + val bootDateTime: String, + val modelName: String +) : ProtocolRspModel + +data class ProtocolThresholdRetrieveRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int, + val insulinDeficiencyAlarmThreshold: Int, + val expiryAlarmThreshold: Int +) : ProtocolRspModel + +data class ProtocolPatchDiscardRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolPatchBuzzInspectionRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolPatchOperationDataRspModel( + override val timestamp: Long, + override val command: Int, + val mode: Int, + val pulseCnt: Int, + val totalNo: Int, + val count: Int, + val useMin: Int, + val remains: Double +) : ProtocolRspModel + +data class ProtocolAppStatusRspModel( + override val timestamp: Long, + override val command: Int, + val status: Int +) : ProtocolRspModel + +data class ProtocolGlucoseMeasurementAlarmTimerRspModel( + override val timestamp: Long, + override val command: Int, + val timerId: Int, + val minutes: Int +) : ProtocolRspModel + +data class ProtocolGlucoseTimerForCGMRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int, + val triggerType: Int +) : ProtocolRspModel + +data class ProtocolGlucoseTimerRptModel( + override val timestamp: Long, + override val command: Int +) : ProtocolRspModel + +data class ProtocolPatchAddressRspModel( + override val timestamp: Long, + override val command: Int, + val macAddress: String, + val checkSum: String +) : ProtocolRspModel + +data class ProtocolWarningMsgRptModel( + override val timestamp: Long, + override val command: Int, + val cause: Int, + val value: Int +) : ProtocolRspModel + +data class ProtocolAlertMsgRptModel( + override val timestamp: Long, + override val command: Int, + val cause: Int, + val value: Int +) : ProtocolRspModel + +data class ProtocolNoticeMsgRptModel( + override val timestamp: Long, + override val command: Int, + val cause: Int, + val value: Int +) : ProtocolRspModel + +data class ProtocolMsgSolutionRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int, + val subId: Int, + val cause: Int +) : ProtocolRspModel + +data class ProtocolPatchInitRspModel( + override val timestamp: Long, + override val command: Int, + val mode: Int +) : ProtocolRspModel + +data class ProtocolPatchRecoveryRptModel( + override val timestamp: Long, + override val command: Int +) : ProtocolRspModel + +data class ProtocolAdditionalPrimingRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolBasalProgramSetRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolAdditionalBasalProgramSetRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolBasalInfusionChangeRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolAdditionalBasalInfusionChangeRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolBasalInfusionResumeRspModel( + override val timestamp: Long, + override val command: Int, + val segmentNo: Int, + val infusionSpeed: Double, + val infusionPeriod: Int, + val insulinRemains: Double +) : ProtocolRspModel + +data class ProtocolTempBasalInfusionRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolBasalInfusionStartRspModel( + override val timestamp: Long, + override val command: Int +) : ProtocolRspModel + +data class ProtocolTempBasalInfusionCancelRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int +) : ProtocolRspModel + +data class ProtocolImmeBolusInfusionRspModel( + override val timestamp: Long, + override val command: Int, + val actionId: Int, + val result: Int, + val expectedTime: Int, + val remains: Double +) : ProtocolRspModel + +data class ProtocolExtendBolusInfusionRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int, + val expectedTime: Int +) : ProtocolRspModel + +data class ProtocolExtendBolusInfusionCancelRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int, + val infusedAmount: Double +) : ProtocolRspModel + +data class ProtocolBolusInfusionCancelRspModel( + override val timestamp: Long, + override val command: Int, + val result: Int, + val insulinRemains: Double, + val infusedAmount: Double +) : ProtocolRspModel + +data class ProtocolExtendBolusDelayRptModel( + override val timestamp: Long, + override val command: Int, + val delayedAmount: Double, + val expectedTime: Int +) : ProtocolRspModel \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/model/ble/CarelevoProtocolBtResponse.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/model/ble/CarelevoProtocolBtResponse.kt new file mode 100644 index 000000000000..4ca3536aaeed --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/model/ble/CarelevoProtocolBtResponse.kt @@ -0,0 +1,7 @@ +package app.aaps.pump.carelevo.data.model.ble + +sealed class BleResponse { + data class RspResponse(val data: T) : BleResponse() + data class Failure(val message: String) : BleResponse() + data class Error(val e: Throwable) : BleResponse() +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/model/entities/CarelevoInfusionInfoEntity.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/model/entities/CarelevoInfusionInfoEntity.kt new file mode 100644 index 000000000000..9f0bcc7d342f --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/model/entities/CarelevoInfusionInfoEntity.kt @@ -0,0 +1,76 @@ +package app.aaps.pump.carelevo.data.model.entities + +import app.aaps.pump.carelevo.domain.type.AlarmCause + +// NOTE: these entities are Gson-(de)serialized. Timestamps are deliberately REQUIRED parameters — +// a `= DateTime.now().toString()` default is both non-deterministic (evaluated at call time) and +// unreliable under Gson, which bypasses Kotlin default-parameter handling on decode. The mappers +// in data/mapper always pass every field explicitly. + +data class CarelevoInfusionInfoEntity( + val basalInfusionInfo: CarelevoBasalInfusionInfoEntity? = null, + val tempBasalInfusionInfo: CarelevoTempBasalInfusionInfoEntity? = null, + val immeBolusInfusionInfo: CarelevoImmeBolusInfusionInfoEntity? = null, + val extendBolusInfusionInfo: CarelevoExtendBolusInfusionInfoEntity? = null +) + +data class CarelevoBasalSegmentInfusionInfoEntity( + val createdAt: String, + val updatedAt: String, + val startTime: Int, + val endTime: Int, + val speed: Double +) + +data class CarelevoBasalInfusionInfoEntity( + val infusionId: String, + val address: String, + val mode: Int, + val createdAt: String, + val updatedAt: String, + val segments: List, + val isStop: Boolean +) + +data class CarelevoTempBasalInfusionInfoEntity( + val infusionId: String, + val address: String, + val mode: Int, + val createdAt: String, + val updatedAt: String, + val percent: Int? = null, + val speed: Double? = null, + val infusionDurationMin: Int? = null +) + +data class CarelevoImmeBolusInfusionInfoEntity( + val infusionId: String, + val address: String, + val mode: Int, + val createdAt: String, + val updatedAt: String, + val volume: Double? = null, + val infusionDurationSeconds: Int? = null +) + +data class CarelevoExtendBolusInfusionInfoEntity( + val infusionId: String, + val address: String, + val mode: Int, + val createdAt: String, + val updatedAt: String, + val volume: Double? = null, + val speed: Double? = null, + val infusionDurationMin: Int? = null +) + +data class CarelevoAlarmInfoEntity( + val alarmId: String, + val alarmType: Int, + val cause: AlarmCause, + val value: Int? = null, + val createdAt: String, + val updatedAt: String, + val acknowledged: Boolean, + val occurrenceCount: Int = 1 +) diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/model/entities/CarelevoPatchInfoEntity.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/model/entities/CarelevoPatchInfoEntity.kt new file mode 100644 index 000000000000..f78fb3a01b25 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/model/entities/CarelevoPatchInfoEntity.kt @@ -0,0 +1,37 @@ +package app.aaps.pump.carelevo.data.model.entities + + +data class CarelevoPatchInfoEntity( + val address: String, + val createdAt: String, + val updatedAt: String, + val manufactureNumber: String? = null, + val firmwareVersion: String? = null, + val bootDateTime: String? = null, + val bootDateTimeUtcMillis: Long? = null, + val modelName: String? = null, + val insulinAmount: Int? = null, + val insulinRemain: Double? = null, + val thresholdInsulinRemain: Int? = null, + val thresholdExpiry: Int? = null, + val thresholdMaxBasalSpeed: Double? = null, + val thresholdMaxBolusDose: Double? = null, + val checkSafety: Boolean? = null, + val checkNeedle: Boolean? = null, + val needleFailedCount: Int? = null, + val isConnected: Boolean? = null, + val needDiscard: Boolean? = null, + val isDiscard: Boolean? = null, + val isExtended: Boolean? = null, + val isValid: Boolean? = null, + val isStopped: Boolean? = null, + val stopMinutes: Int? = null, + val stopMode: Int? = null, + val isForceStopped: Boolean? = null, + val runningMinutes: Int? = null, + val infusedTotalBasalAmount: Double? = null, + val infusedTotalBolusAmount: Double? = null, + val pumpState: Int? = null, + val mode: Int? = null, + val bolusActionSeq: Int? = null +) diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/model/entities/CarelevoUserSettingInfoEntity.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/model/entities/CarelevoUserSettingInfoEntity.kt new file mode 100644 index 000000000000..41b338f87468 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/model/entities/CarelevoUserSettingInfoEntity.kt @@ -0,0 +1,13 @@ +package app.aaps.pump.carelevo.data.model.entities + + +data class CarelevoUserSettingInfoEntity( + val createdAt: String, + val updatedAt: String, + val lowInsulinNoticeAmount: Int? = null, + val maxBasalSpeed: Double? = null, + val maxBolusDose: Double? = null, + val needLowInsulinNoticeAmountSyncPatch: Boolean = false, + val needMaxBasalSpeedSyncPatch: Boolean = false, + val needMaxBolusDoseSyncPatch: Boolean = false +) \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoAlarmInfoLocalRepositoryImpl.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoAlarmInfoLocalRepositoryImpl.kt new file mode 100644 index 000000000000..21568c3acf7d --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoAlarmInfoLocalRepositoryImpl.kt @@ -0,0 +1,45 @@ +package app.aaps.pump.carelevo.data.repository + +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoAlarmInfoLocalDataSource +import app.aaps.pump.carelevo.data.mapper.transformToDomainModel +import app.aaps.pump.carelevo.data.mapper.transformToEntity +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.repository.CarelevoAlarmInfoRepository +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import java.util.Optional +import javax.inject.Inject + +class CarelevoAlarmInfoLocalRepositoryImpl @Inject constructor( + private val dataSource: CarelevoAlarmInfoLocalDataSource +) : CarelevoAlarmInfoRepository { + + override fun observeAlarms(): Observable>> = + dataSource.observeAlarms() // Observable>> + .map { opt -> + val list = opt.orElse(emptyList()) + .map { it.transformToDomainModel() } + Optional.of(list) + } + + override fun getAlarmsOnce(): Single>> = + dataSource.getAlarmsOnce() // Single>> + .map { opt -> + val list = opt.orElse(emptyList()) + .map { it.transformToDomainModel() } + Optional.of(list) + } + + override fun setAlarms(list: List): Completable = + dataSource.setAlarms(list.map { it.transformToEntity() }) + + override fun upsertAlarm(alarm: CarelevoAlarmInfo): Completable = + dataSource.upsertAlarm(alarm.transformToEntity()) + + override fun removeAlarm(alarmId: String): Completable = + dataSource.removeAlarm(alarmId) + + override fun clearAlarms(): Completable = + dataSource.clearAlarms() +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoInfusionInfoRepositoryImpl.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoInfusionInfoRepositoryImpl.kt new file mode 100644 index 000000000000..0ef9f5f982e4 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoInfusionInfoRepositoryImpl.kt @@ -0,0 +1,70 @@ +package app.aaps.pump.carelevo.data.repository + +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoInfusionInfoDataSource +import app.aaps.pump.carelevo.data.mapper.transformToCarelevoBasalInfusionInfoEntity +import app.aaps.pump.carelevo.data.mapper.transformToCarelevoExtendBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.mapper.transformToCarelevoImmeBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.mapper.transformToCarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.data.mapper.transformToCarelevoTempBasalInfusionInfoEntity +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoExtendBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoImmeBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoTempBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import io.reactivex.rxjava3.core.Observable +import java.util.Optional +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +class CarelevoInfusionInfoRepositoryImpl @Inject constructor( + private val infusionInfoDataSource: CarelevoInfusionInfoDataSource +) : CarelevoInfusionInfoRepository { + + override fun getInfusionInfo(): Observable> { + return infusionInfoDataSource.getInfusionInfo() + .map { + Optional.ofNullable(it.getOrNull()?.transformToCarelevoInfusionInfoDomainModel()) + } + } + + override fun getInfusionInfoBySync(): CarelevoInfusionInfoDomainModel? { + return infusionInfoDataSource.getInfusionInfoBySync()?.transformToCarelevoInfusionInfoDomainModel() + } + + override fun updateBasalInfusionInfo(info: CarelevoBasalInfusionInfoDomainModel): Boolean { + return infusionInfoDataSource.updateBasalInfusionInfo(info.transformToCarelevoBasalInfusionInfoEntity()) + } + + override fun updateTempBasalInfusionInfo(info: CarelevoTempBasalInfusionInfoDomainModel): Boolean { + return infusionInfoDataSource.updateTempBasalInfusionInfo(info.transformToCarelevoTempBasalInfusionInfoEntity()) + } + + override fun updateImmeBolusInfusionInfo(info: CarelevoImmeBolusInfusionInfoDomainModel): Boolean { + return infusionInfoDataSource.updateImmeBolusInfusionInfo(info.transformToCarelevoImmeBolusInfusionInfoEntity()) + } + + override fun updateExtendBolusInfusionInfo(info: CarelevoExtendBolusInfusionInfoDomainModel): Boolean { + return infusionInfoDataSource.updateExtendBolusInfusionInfo(info.transformToCarelevoExtendBolusInfusionInfoEntity()) + } + + override fun deleteBasalInfusionInfo(): Boolean { + return infusionInfoDataSource.deleteBasalInfusionInfo() + } + + override fun deleteTempBasalInfusionInfo(): Boolean { + return infusionInfoDataSource.deleteTempBasalInfusionInfo() + } + + override fun deleteImmeBolusInfusionInfo(): Boolean { + return infusionInfoDataSource.deleteImmeBolusInfusionInfo() + } + + override fun deleteExtendBolusInfusionInfo(): Boolean { + return infusionInfoDataSource.deleteExtendBolusInfusionInfo() + } + + override fun deleteInfusionInfo(): Boolean { + return infusionInfoDataSource.deleteInfusionInfo() + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoPatchInfoRepositoryImpl.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoPatchInfoRepositoryImpl.kt new file mode 100644 index 000000000000..39ae6938c8ae --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoPatchInfoRepositoryImpl.kt @@ -0,0 +1,33 @@ +package app.aaps.pump.carelevo.data.repository + +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoPatchInfoDataSource +import app.aaps.pump.carelevo.data.mapper.transformToCarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.data.mapper.transformToCarelevoPatchInfoEntity +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import io.reactivex.rxjava3.core.Observable +import java.util.Optional +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +class CarelevoPatchInfoRepositoryImpl @Inject constructor( + private val patchInfoDataSource: CarelevoPatchInfoDataSource +) : CarelevoPatchInfoRepository { + + override fun getPatchInfo(): Observable> { + return patchInfoDataSource.getPatchInfo() + .map { Optional.ofNullable(it.getOrNull()?.transformToCarelevoPatchInfoDomainModel()) } + } + + override fun getPatchInfoBySync(): CarelevoPatchInfoDomainModel? { + return patchInfoDataSource.getPatchInfoBySync()?.transformToCarelevoPatchInfoDomainModel() + } + + override fun updatePatchInfo(info: CarelevoPatchInfoDomainModel): Boolean { + return patchInfoDataSource.updatePatchInfo(info.transformToCarelevoPatchInfoEntity()) + } + + override fun deletePatchInfo(): Boolean { + return patchInfoDataSource.deletePatchInfo() + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoUserSettingInfoRepositoryImpl.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoUserSettingInfoRepositoryImpl.kt new file mode 100644 index 000000000000..3222a3287c7f --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoUserSettingInfoRepositoryImpl.kt @@ -0,0 +1,33 @@ +package app.aaps.pump.carelevo.data.repository + +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoUserSettingInfoDataSource +import app.aaps.pump.carelevo.data.mapper.transformToCarelevoUserSettingInfoDomainModel +import app.aaps.pump.carelevo.data.mapper.transformToCarelevoUserSettingInfoEntity +import app.aaps.pump.carelevo.domain.model.userSetting.CarelevoUserSettingInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoUserSettingInfoRepository +import io.reactivex.rxjava3.core.Observable +import java.util.Optional +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +class CarelevoUserSettingInfoRepositoryImpl @Inject constructor( + private val userSettingInfoDataSource: CarelevoUserSettingInfoDataSource +) : CarelevoUserSettingInfoRepository { + + override fun getUserSettingInfo(): Observable> { + return userSettingInfoDataSource.getUserSettingInfo() + .map { Optional.ofNullable(it.getOrNull()?.transformToCarelevoUserSettingInfoDomainModel()) } + } + + override fun getUserSettingInfoBySync(): CarelevoUserSettingInfoDomainModel? { + return userSettingInfoDataSource.getUserSettingInfoBySync()?.transformToCarelevoUserSettingInfoDomainModel() + } + + override fun updateUserSettingInfo(info: CarelevoUserSettingInfoDomainModel): Boolean { + return userSettingInfoDataSource.updateUserSettingInfo(info.transformToCarelevoUserSettingInfoEntity()) + } + + override fun deleteUserSettingInfo(): Boolean { + return userSettingInfoDataSource.deleteUserSettingInfo() + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoBleModule.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoBleModule.kt new file mode 100644 index 000000000000..4700a2ea3960 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoBleModule.kt @@ -0,0 +1,37 @@ +package app.aaps.pump.carelevo.di + +import app.aaps.pump.carelevo.config.BleEnvConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.UUID +import javax.inject.Named + +/** + * GATT identifiers for the CareLevo transport. + * + * The [app.aaps.pump.carelevo.ble.CarelevoBleTransport] binding itself lives in the app's + * `withPumps` source set (`CarelevoModules`), so the real impl and the emulator can be selected + * per build — the same arrangement Dana and Equil use. + */ +@Module +@InstallIn(SingletonComponent::class) +class CarelevoBleModule { + + @Provides + @Named("cccDescriptor") + internal fun provideCccDescriptor(): UUID = UUID.fromString(BleEnvConfig.BLE_CCC_DESCRIPTOR) + + @Provides + @Named("serviceUuid") + internal fun provideServiceUuid(): UUID = UUID.fromString(BleEnvConfig.BLE_SERVICE_UUID) + + @Provides + @Named("characterTx") + internal fun provideTxCharacteristicUuid(): UUID = UUID.fromString(BleEnvConfig.BLE_TX_CHAR_UUID) + + @Provides + @Named("characterRx") + internal fun provideRxCharacteristicUuid(): UUID = UUID.fromString(BleEnvConfig.BLE_RX_CHAR_UUID) +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoDaoModule.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoDaoModule.kt new file mode 100644 index 000000000000..3cbbd8119c34 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoDaoModule.kt @@ -0,0 +1,61 @@ +package app.aaps.pump.carelevo.di + +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.pump.carelevo.data.dao.CarelevoAlarmInfoDao +import app.aaps.pump.carelevo.data.dao.CarelevoAlarmInfoDaoImpl +import app.aaps.pump.carelevo.data.dao.CarelevoInfusionInfoDao +import app.aaps.pump.carelevo.data.dao.CarelevoInfusionInfoDaoImpl +import app.aaps.pump.carelevo.data.dao.CarelevoPatchInfoDao +import app.aaps.pump.carelevo.data.dao.CarelevoPatchInfoDaoImpl +import app.aaps.pump.carelevo.data.dao.CarelevoUserSettingInfoDao +import app.aaps.pump.carelevo.data.dao.CarelevoUserSettingInfoDaoImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class CarelevoDaoModule { + + @Provides + @Singleton + fun provideCarelevoInfusionInfoDao( + prefManager: SP + ): CarelevoInfusionInfoDao { + return CarelevoInfusionInfoDaoImpl( + prefManager + ) + } + + @Provides + @Singleton + fun provideCarelevoPatchInfoDao( + prefManager: SP + ): CarelevoPatchInfoDao { + return CarelevoPatchInfoDaoImpl( + prefManager + ) + } + + @Provides + @Singleton + fun provideCarelevoUserSettingInfoDao( + prefManager: SP + ): CarelevoUserSettingInfoDao { + return CarelevoUserSettingInfoDaoImpl( + prefManager + ) + } + + @Provides + @Singleton + fun provideCarelevoAlarmInfoDao( + prefManager: SP + ): CarelevoAlarmInfoDao { + return CarelevoAlarmInfoDaoImpl( + prefManager + ) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoDataSourceModule.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoDataSourceModule.kt new file mode 100644 index 000000000000..6f0d641c38e7 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoDataSourceModule.kt @@ -0,0 +1,57 @@ +package app.aaps.pump.carelevo.di + +import app.aaps.pump.carelevo.data.dao.CarelevoAlarmInfoDao +import app.aaps.pump.carelevo.data.dao.CarelevoInfusionInfoDao +import app.aaps.pump.carelevo.data.dao.CarelevoPatchInfoDao +import app.aaps.pump.carelevo.data.dao.CarelevoUserSettingInfoDao +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoAlarmInfoLocalDataSource +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoAlarmInfoLocalDataSourceImpl +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoInfusionInfoDataSource +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoInfusionInfoDataSourceImpl +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoPatchInfoDataSource +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoPatchInfoDataSourceImpl +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoUserSettingInfoDataSource +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoUserSettingInfoDataSourceImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +class CarelevoDataSourceModule { + + @Provides + fun provideCarelevoInfusionInfoDataSource( + carelevoInfusionInfoDao: CarelevoInfusionInfoDao + ): CarelevoInfusionInfoDataSource { + return CarelevoInfusionInfoDataSourceImpl( + carelevoInfusionInfoDao + ) + } + + @Provides + fun provideCarelevoPatchInfoDataSource( + carelevoPatchInfoDao: CarelevoPatchInfoDao + ): CarelevoPatchInfoDataSource { + return CarelevoPatchInfoDataSourceImpl( + carelevoPatchInfoDao + ) + } + + @Provides + fun provideCarelevoUserSettingInfoDataSource( + carelevoUserSettingInfoDao: CarelevoUserSettingInfoDao + ): CarelevoUserSettingInfoDataSource { + return CarelevoUserSettingInfoDataSourceImpl( + carelevoUserSettingInfoDao + ) + } + + @Provides + fun provideCarelevoAlarmInfoLocalDataSource( + dao: CarelevoAlarmInfoDao + ): CarelevoAlarmInfoLocalDataSource { + return CarelevoAlarmInfoLocalDataSourceImpl(dao) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoManagerModule.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoManagerModule.kt new file mode 100644 index 000000000000..0b1ec67a43d9 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoManagerModule.kt @@ -0,0 +1,60 @@ +package app.aaps.pump.carelevo.di + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.pump.carelevo.ble.CarelevoBleTransport +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.domain.usecase.alarm.CarelevoAlarmInfoUseCase +import app.aaps.pump.carelevo.domain.usecase.infusion.CarelevoInfusionInfoMonitorUseCase +import app.aaps.pump.carelevo.domain.usecase.infusion.CarelevoPumpResumeUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchInfoMonitorUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchRptInfusionInfoProcessUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoCreateUserSettingInfoUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoUserSettingInfoMonitorUseCase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class CarelevoManagerModule { + + @Provides + @Singleton + fun provideCarelevoPatch( + transport: CarelevoBleTransport, + aapsSchedulers: AapsSchedulers, + rxBus: RxBus, + sp: SP, + preferences: Preferences, + aapsLogger: AAPSLogger, + carelevoInfusionInfoMonitorUseCase: CarelevoInfusionInfoMonitorUseCase, + carelevoPatchInfoMonitorUseCase: CarelevoPatchInfoMonitorUseCase, + carelevoUserSettingInfoMonitorUseCase: CarelevoUserSettingInfoMonitorUseCase, + carelevoPatchRptInfusionInfoProcessUseCase: CarelevoPatchRptInfusionInfoProcessUseCase, + carelevoCreateUserSettingInfoUserCase: CarelevoCreateUserSettingInfoUseCase, + carelevoAlarmInfoUseCase: CarelevoAlarmInfoUseCase, + carelevoPumpResumeUseCase: CarelevoPumpResumeUseCase + ): CarelevoPatch { + return CarelevoPatch( + transport, + aapsSchedulers, + rxBus, + sp, + preferences, + aapsLogger, + carelevoInfusionInfoMonitorUseCase, + carelevoPatchInfoMonitorUseCase, + carelevoUserSettingInfoMonitorUseCase, + carelevoPatchRptInfusionInfoProcessUseCase, + carelevoCreateUserSettingInfoUserCase, + carelevoAlarmInfoUseCase, + carelevoPumpResumeUseCase + ) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoModule.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoModule.kt new file mode 100644 index 000000000000..c3087e440aef --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoModule.kt @@ -0,0 +1,33 @@ +package app.aaps.pump.carelevo.di + +import app.aaps.core.interfaces.di.PumpDriver +import app.aaps.core.interfaces.plugin.PluginBase +import app.aaps.pump.carelevo.CarelevoPumpPlugin +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntKey +import dagger.multibindings.IntoMap + +@Module( + includes = [ + CarelevoBleModule::class, + CarelevoDataSourceModule::class, + CarelevoDaoModule::class, + CarelevoManagerModule::class, + CarelevoRepositoryModule::class, + CarelevoUseCaseModule::class + ] +) +@InstallIn(SingletonComponent::class) +@Suppress("unused") +abstract class CarelevoModule { + + // Pump plugin registration — @IntKey range 1000–1200, see PluginsListModule for overview + @Binds + @PumpDriver + @IntoMap + @IntKey(1190) + abstract fun bindCarelevoPumpPlugin(plugin: CarelevoPumpPlugin): PluginBase +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoRepositoryModule.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoRepositoryModule.kt new file mode 100644 index 000000000000..805bfcb395a9 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoRepositoryModule.kt @@ -0,0 +1,57 @@ +package app.aaps.pump.carelevo.di + +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoAlarmInfoLocalDataSource +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoInfusionInfoDataSource +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoPatchInfoDataSource +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoUserSettingInfoDataSource +import app.aaps.pump.carelevo.data.repository.CarelevoAlarmInfoLocalRepositoryImpl +import app.aaps.pump.carelevo.data.repository.CarelevoInfusionInfoRepositoryImpl +import app.aaps.pump.carelevo.data.repository.CarelevoPatchInfoRepositoryImpl +import app.aaps.pump.carelevo.data.repository.CarelevoUserSettingInfoRepositoryImpl +import app.aaps.pump.carelevo.domain.repository.CarelevoAlarmInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoUserSettingInfoRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +class CarelevoRepositoryModule { + + @Provides + fun provideCarelevoInfusionInfoRepository( + carelevoInfusionInfoDataSource: CarelevoInfusionInfoDataSource + ): CarelevoInfusionInfoRepository { + return CarelevoInfusionInfoRepositoryImpl( + carelevoInfusionInfoDataSource + ) + } + + @Provides + fun provideCarelevoPatchInfoRepository( + carelevoPatchInfoDataSource: CarelevoPatchInfoDataSource + ): CarelevoPatchInfoRepository { + return CarelevoPatchInfoRepositoryImpl( + carelevoPatchInfoDataSource + ) + } + + @Provides + fun provideCarelevoUserSettingInfoRepository( + carelevoUserSettingInfoDataSource: CarelevoUserSettingInfoDataSource + ): CarelevoUserSettingInfoRepository { + return CarelevoUserSettingInfoRepositoryImpl( + carelevoUserSettingInfoDataSource + ) + } + + @Provides + fun provideCarelevoAlarmInfoLocalRepository( + carelevoAlarmInfoLocalDataSource: CarelevoAlarmInfoLocalDataSource + ): CarelevoAlarmInfoRepository { + return CarelevoAlarmInfoLocalRepositoryImpl(carelevoAlarmInfoLocalDataSource) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoUseCaseModule.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoUseCaseModule.kt new file mode 100644 index 000000000000..6802f3c2d877 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/di/CarelevoUseCaseModule.kt @@ -0,0 +1,298 @@ +package app.aaps.pump.carelevo.di + +import app.aaps.pump.carelevo.domain.repository.CarelevoAlarmInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoUserSettingInfoRepository +import app.aaps.pump.carelevo.domain.usecase.alarm.AlarmClearPatchDiscardUseCase +import app.aaps.pump.carelevo.domain.usecase.alarm.AlarmClearRequestUseCase +import app.aaps.pump.carelevo.domain.usecase.alarm.CarelevoAlarmInfoUseCase +import app.aaps.pump.carelevo.domain.usecase.basal.CarelevoCancelTempBasalInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.basal.CarelevoSetBasalProgramUseCase +import app.aaps.pump.carelevo.domain.usecase.basal.CarelevoStartTempBasalInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoCancelExtendBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoCancelImmeBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoFinishImmeBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoStartExtendBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoStartImmeBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.infusion.CarelevoInfusionInfoMonitorUseCase +import app.aaps.pump.carelevo.domain.usecase.infusion.CarelevoPumpResumeUseCase +import app.aaps.pump.carelevo.domain.usecase.infusion.CarelevoPumpStopUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoConnectNewPatchUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchForceDiscardUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchInfoMonitorUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchNeedleInsertionCheckUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchRptInfusionInfoProcessUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchSafetyCheckUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoCreateUserSettingInfoUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoDeleteUserSettingInfoUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoUpdateLowInsulinNoticeAmountUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoUpdateMaxBolusDoseUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoUserSettingInfoMonitorUseCase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +class CarelevoUseCaseModule { + + @Provides + fun provideCarelevoConnectNewPatchUseCase( + carelevoPatchInfoRepository: CarelevoPatchInfoRepository + ): CarelevoConnectNewPatchUseCase { + return CarelevoConnectNewPatchUseCase( + carelevoPatchInfoRepository + ) + } + + @Provides + fun provideCarelevoInfusionInfoMonitorUseCase( + carelevoInfusionInfoRepository: CarelevoInfusionInfoRepository + ): CarelevoInfusionInfoMonitorUseCase { + return CarelevoInfusionInfoMonitorUseCase( + carelevoInfusionInfoRepository + ) + } + + @Provides + fun provideCarelevoPatchInfoMonitorUseCase( + carelevoPatchInfoRepository: CarelevoPatchInfoRepository + ): CarelevoPatchInfoMonitorUseCase { + return CarelevoPatchInfoMonitorUseCase( + carelevoPatchInfoRepository + ) + } + + @Provides + fun provideCarelevoUserSettingInfoMonitorUseCase( + carelevoUserSettingInfoRepository: CarelevoUserSettingInfoRepository + ): CarelevoUserSettingInfoMonitorUseCase { + return CarelevoUserSettingInfoMonitorUseCase( + carelevoUserSettingInfoRepository + ) + } + + //========================================================================================== + // about basal + @Provides + fun provideCarelevoSetBasalProgramUseCase( + carelevoPatchInfoRepository: CarelevoPatchInfoRepository, + carelevoInfusionInfoRepository: CarelevoInfusionInfoRepository + ): CarelevoSetBasalProgramUseCase { + return CarelevoSetBasalProgramUseCase( + carelevoPatchInfoRepository, + carelevoInfusionInfoRepository + ) + } + + @Provides + fun provideCarelevoStartTempBasalInfusionUseCase( + carelevoPatchInfoRepository: CarelevoPatchInfoRepository, + carelevoInfusionInfoRepository: CarelevoInfusionInfoRepository + ): CarelevoStartTempBasalInfusionUseCase { + return CarelevoStartTempBasalInfusionUseCase( + carelevoPatchInfoRepository, + carelevoInfusionInfoRepository + ) + } + + @Provides + fun provideCarelevoCancelTempBasalInfusionUseCase( + carelevoPatchInfoRepository: CarelevoPatchInfoRepository, + carelevoInfusionInfoRepository: CarelevoInfusionInfoRepository + ): CarelevoCancelTempBasalInfusionUseCase { + return CarelevoCancelTempBasalInfusionUseCase( + carelevoPatchInfoRepository, + carelevoInfusionInfoRepository + ) + } + + //========================================================================================== + // about bolus + @Provides + fun provideCarelevoStartImmeBolusInfusionUseCase( + carelevoPatchInfoRepository: CarelevoPatchInfoRepository, + carelevoInfusionInfoRepository: CarelevoInfusionInfoRepository + ): CarelevoStartImmeBolusInfusionUseCase { + return CarelevoStartImmeBolusInfusionUseCase( + carelevoPatchInfoRepository, + carelevoInfusionInfoRepository + ) + } + + @Provides + fun provideCarelevoStartExtendBolusInfusionUseCase( + carelevoPatchInfoRepository: CarelevoPatchInfoRepository, + carelevoInfusionInfoRepository: CarelevoInfusionInfoRepository + ): CarelevoStartExtendBolusInfusionUseCase { + return CarelevoStartExtendBolusInfusionUseCase( + carelevoPatchInfoRepository, + carelevoInfusionInfoRepository + ) + } + + @Provides + fun provideCarelevoCancelImmeBolusInfusionUseCase( + carelevoPatchInfoRepository: CarelevoPatchInfoRepository, + carelevoInfusionInfoRepository: CarelevoInfusionInfoRepository + ): CarelevoCancelImmeBolusInfusionUseCase { + return CarelevoCancelImmeBolusInfusionUseCase( + carelevoPatchInfoRepository, + carelevoInfusionInfoRepository + ) + } + + @Provides + fun provideCarelevoCancelExtendBolusInfusionUseCase( + carelevoPatchInfoRepository: CarelevoPatchInfoRepository, + carelevoInfusionInfoRepository: CarelevoInfusionInfoRepository + ): CarelevoCancelExtendBolusInfusionUseCase { + return CarelevoCancelExtendBolusInfusionUseCase( + carelevoPatchInfoRepository, + carelevoInfusionInfoRepository + ) + } + + @Provides + fun provideCarelevoFinishImmeBolusInfusionUseCase( + carelevoPatchInfoRepository: CarelevoPatchInfoRepository, + carelevoInfusionInfoRepository: CarelevoInfusionInfoRepository + ): CarelevoFinishImmeBolusInfusionUseCase { + return CarelevoFinishImmeBolusInfusionUseCase( + carelevoPatchInfoRepository, + carelevoInfusionInfoRepository + ) + } + + //========================================================================================== + // about user setting info + @Provides + fun provideCarelevoUpdateMaxBolusDoseUseCase( + carelevoInfusionInfoRepository: CarelevoInfusionInfoRepository, + carelevoUserSettingInfoRepository: CarelevoUserSettingInfoRepository + ): CarelevoUpdateMaxBolusDoseUseCase { + return CarelevoUpdateMaxBolusDoseUseCase( + carelevoInfusionInfoRepository, + carelevoUserSettingInfoRepository + ) + } + + @Provides + fun provideCarelevoUpdateLowInsulinNoticeAmountUseCase( + carelevoUserSettingInfoRepository: CarelevoUserSettingInfoRepository + ): CarelevoUpdateLowInsulinNoticeAmountUseCase { + return CarelevoUpdateLowInsulinNoticeAmountUseCase( + carelevoUserSettingInfoRepository + ) + } + + @Provides + fun provideCarelevoDeleteUserSettingInfoUseCase( + carelevoUserSettingInfoRepository: CarelevoUserSettingInfoRepository + ): CarelevoDeleteUserSettingInfoUseCase { + return CarelevoDeleteUserSettingInfoUseCase( + carelevoUserSettingInfoRepository + ) + } + + @Provides + fun provideCarelevoCreateUserSettingInfoUseCase( + carelevoUserSettingInfoRepository: CarelevoUserSettingInfoRepository + ): CarelevoCreateUserSettingInfoUseCase { + return CarelevoCreateUserSettingInfoUseCase( + carelevoUserSettingInfoRepository + ) + } + + //========================================================================================== + // about patch + @Provides + fun provideCarelevoPatchRptInfusionInfoProcessUseCase( + carelevoPatchInfoRepository: CarelevoPatchInfoRepository + ): CarelevoPatchRptInfusionInfoProcessUseCase { + return CarelevoPatchRptInfusionInfoProcessUseCase( + carelevoPatchInfoRepository + ) + } + + @Provides + fun provideCarelevoPatchForceDiscardUseCase( + carelevoPatchInfoRepository: CarelevoPatchInfoRepository, + carelevoInfusionInfoRepository: CarelevoInfusionInfoRepository, + carelevoUserSettingInfoRepository: CarelevoUserSettingInfoRepository + ): CarelevoPatchForceDiscardUseCase { + return CarelevoPatchForceDiscardUseCase( + carelevoPatchInfoRepository, + carelevoInfusionInfoRepository, + carelevoUserSettingInfoRepository + ) + } + + @Provides + fun provideCarelevoPatchSafetyCheckUseCase( + carelevoPatchInfoRepository: CarelevoPatchInfoRepository + ): CarelevoPatchSafetyCheckUseCase { + return CarelevoPatchSafetyCheckUseCase( + carelevoPatchInfoRepository + ) + } + + @Provides + fun provideCarelevoPatchCannulaInsertionCheckUseCase( + carelevoPatchInfoRepository: CarelevoPatchInfoRepository + ): CarelevoPatchNeedleInsertionCheckUseCase { + return CarelevoPatchNeedleInsertionCheckUseCase( + carelevoPatchInfoRepository + ) + } + + //========================================================================================== + // about infusion + @Provides + fun provideCarelevoPumpResumeUseCase( + carelevoPatchInfoRepository: CarelevoPatchInfoRepository, + carelevoInfusionInfoRepository: CarelevoInfusionInfoRepository + ): CarelevoPumpResumeUseCase { + return CarelevoPumpResumeUseCase( + carelevoPatchInfoRepository, + carelevoInfusionInfoRepository + ) + } + + @Provides + fun provideCarelevoPumpStopUseCase( + carelevoPatchInfoRepository: CarelevoPatchInfoRepository, + carelevoInfusionInfoRepository: CarelevoInfusionInfoRepository + ): CarelevoPumpStopUseCase { + return CarelevoPumpStopUseCase( + carelevoPatchInfoRepository, + carelevoInfusionInfoRepository + ) + } + + @Provides + fun provideCarelevoAlarmInfoUseCase( + carelevoAlarmInfoRepository: CarelevoAlarmInfoRepository + ): CarelevoAlarmInfoUseCase { + return CarelevoAlarmInfoUseCase(carelevoAlarmInfoRepository) + } + + @Provides + fun provideAlarmClearRequestUseCase( + alarmRepository: CarelevoAlarmInfoRepository + ): AlarmClearRequestUseCase { + return AlarmClearRequestUseCase(alarmRepository) + } + + @Provides + fun provideAlarmClearPatchDiscardUseCase( + alarmRepository: CarelevoAlarmInfoRepository, + patchInfoRepository: CarelevoPatchInfoRepository, + userSettingInfoRepository: CarelevoUserSettingInfoRepository, + infusionInfoRepository: CarelevoInfusionInfoRepository + ): AlarmClearPatchDiscardUseCase { + return AlarmClearPatchDiscardUseCase(alarmRepository, patchInfoRepository, userSettingInfoRepository, infusionInfoRepository) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/ext/CarelevoDomainValueExt.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/ext/CarelevoDomainValueExt.kt new file mode 100644 index 000000000000..92d6bcb04354 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/ext/CarelevoDomainValueExt.kt @@ -0,0 +1,35 @@ +package app.aaps.pump.carelevo.domain.ext + +import app.aaps.pump.carelevo.domain.model.basal.CarelevoBasalSegmentDomainModel +import java.util.UUID + +internal fun List.splitSegment(): List { + val normalized = this + .map { seg -> + val startHour = (seg.startTime / 60).coerceIn(0, 23) + val endHourRaw = (seg.endTime / 60) + + val endHour = endHourRaw.coerceIn(startHour + 1, 24) + seg.copy(startTime = startHour, endTime = endHour) + } + .sortedBy { it.startTime } + + val hourly = mutableListOf() + + for (hour in 0..23) { + val seg = normalized.lastOrNull { it.startTime <= hour && hour < it.endTime } + hourly.add( + CarelevoBasalSegmentDomainModel( + startTime = hour, + endTime = hour + 1, + speed = seg?.speed ?: 0.0 + ) + ) + } + + return hourly +} + +internal fun generateUUID(): String { + return UUID.randomUUID().toString() +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/CarelevoDomainInterfaces.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/CarelevoDomainInterfaces.kt new file mode 100644 index 000000000000..ff08f6444ed5 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/CarelevoDomainInterfaces.kt @@ -0,0 +1,17 @@ +package app.aaps.pump.carelevo.domain.model + +interface RepositoryRequest +interface RepositoryResponse + +sealed class ResponseResult { + data class Success(val data: T?) : ResponseResult() + data class Failure(val message: String) : ResponseResult() + data class Error(val e: Throwable) : ResponseResult() +} + +sealed class RequestResult { + data class Pending(val data: T) : RequestResult() + data class Success(val data: T) : RequestResult() + data class Failure(val message: String) : RequestResult() + data class Error(val e: Throwable) : RequestResult() +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/alarm/CarelevoAlarmInfo.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/alarm/CarelevoAlarmInfo.kt new file mode 100644 index 000000000000..ebf0ce911016 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/alarm/CarelevoAlarmInfo.kt @@ -0,0 +1,15 @@ +package app.aaps.pump.carelevo.domain.model.alarm + +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType + +data class CarelevoAlarmInfo( + val alarmId: String, + val alarmType: AlarmType, + val cause: AlarmCause, + val value: Int? = null, + val createdAt: String, + val updatedAt: String, + val isAcknowledged: Boolean, + val occurrenceCount: Int? = null +) diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/basal/CarelevoBasalModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/basal/CarelevoBasalModel.kt new file mode 100644 index 000000000000..105bbb2a3348 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/basal/CarelevoBasalModel.kt @@ -0,0 +1,13 @@ +package app.aaps.pump.carelevo.domain.model.basal + +data class CarelevoBasalSegment( + val injectStartHour: Int, + val injectStartMin: Int, + val injectSpeed: Double +) + +data class CarelevoBasalSegmentDomainModel( + val startTime: Int, + val endTime: Int, + val speed: Double +) \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoBtEnums.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoBtEnums.kt new file mode 100644 index 000000000000..35f1cd5e5677 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoBtEnums.kt @@ -0,0 +1,371 @@ +package app.aaps.pump.carelevo.domain.model.bt + +enum class Result { + SUCCESS, + FAILED; + + companion object { + + fun Result.commandToCode() = when (this) { + SUCCESS -> 0 + FAILED -> 1 + } + + fun Int.codeToResultCommand() = when (this) { + 0 -> SUCCESS + 1 -> FAILED + else -> FAILED + } + } +} + +enum class SafetyCheckResult { + SUCCESS, + INSULIN_DEFICIENCY, + EXPIRED, + LOW_VOLTAGE, + PATCH_ERROR, + PUMP_ERROR, + REP_REQUEST, + REP_REQUEST1, + FAILED; + + companion object { + + fun SafetyCheckResult.commandToCode() = when (this) { + SUCCESS -> 0 + INSULIN_DEFICIENCY -> 1 + EXPIRED -> 2 + LOW_VOLTAGE -> 3 + PATCH_ERROR -> 11 + PUMP_ERROR -> 12 + REP_REQUEST -> 4 + REP_REQUEST1 -> 18 + else -> -1 + } + + fun Int.codeToSafetyCheckCommand() = when (this) { + 0 -> SUCCESS + 1 -> INSULIN_DEFICIENCY + 2 -> EXPIRED + 3 -> LOW_VOLTAGE + 11 -> PATCH_ERROR + 12 -> PUMP_ERROR + 4 -> REP_REQUEST + 18 -> REP_REQUEST1 + else -> FAILED + } + } +} + +enum class SetBasalProgramResult { + SUCCESS, + INSULIN_DEFICIENCY, + EXPIRED, + LOW_VOLTAGE, + ABNORMAL_TEMP, + PUMP_ERROR, + ABNORMAL_PROGRAM, + EXCEED_LIMIT, + FAILED; + + companion object { + + fun SetBasalProgramResult.commandToCode() = when (this) { + SUCCESS -> 0 + INSULIN_DEFICIENCY -> 1 + EXPIRED -> 2 + LOW_VOLTAGE -> 3 + ABNORMAL_TEMP -> 4 + PUMP_ERROR -> 12 + ABNORMAL_PROGRAM -> 19 + EXCEED_LIMIT -> 20 + else -> -1 + } + + fun Int.codeToSetBasalProgramCommand() = when (this) { + 0 -> SUCCESS + 1 -> INSULIN_DEFICIENCY + 2 -> EXPIRED + 3 -> LOW_VOLTAGE + 4 -> ABNORMAL_TEMP + 12 -> PUMP_ERROR + 19 -> ABNORMAL_PROGRAM + 20 -> EXCEED_LIMIT + else -> FAILED + } + } +} + +enum class SetBolusProgramResult { + SUCCESS, + INSULIN_DEFICIENCY, + EXPIRED, + LOW_VOLTAGE, + ABNORMAL_TEMP, + PUMP_ERROR, + EXCEED_LIMIT, + FAILED; + + companion object { + + fun SetBolusProgramResult.commandToCode() = when (this) { + SUCCESS -> 0 + INSULIN_DEFICIENCY -> 1 + EXPIRED -> 2 + LOW_VOLTAGE -> 3 + ABNORMAL_TEMP -> 4 + PUMP_ERROR -> 12 + EXCEED_LIMIT -> 20 + else -> -1 + } + + fun Int.codeToSetBolusProgramCommand() = when (this) { + 0 -> SUCCESS + 1 -> INSULIN_DEFICIENCY + 2 -> EXPIRED + 3 -> LOW_VOLTAGE + 4 -> ABNORMAL_TEMP + 12 -> PUMP_ERROR + 20 -> EXCEED_LIMIT + else -> FAILED + } + } +} + +enum class StopPumpResult { + BY_REQ, + INSULIN_DEFICIENCY, + ABNORMAL_PUMP, + LOW_VOLTAGE, + ABNORMAL_TEMP, + NOT_USED, + PUMP_ERROR, + BY_LGS, + ERROR; + + companion object { + + fun StopPumpResult.commandToCode() = when (this) { + BY_REQ -> 0 + INSULIN_DEFICIENCY -> 1 + ABNORMAL_PUMP -> 2 + LOW_VOLTAGE -> 3 + ABNORMAL_TEMP -> 4 + NOT_USED -> 5 + PUMP_ERROR -> 12 + BY_LGS -> 29 + ERROR -> -1 + } + + fun Int.codeToStopPumpCommand() = when (this) { + 0 -> BY_REQ + 1 -> INSULIN_DEFICIENCY + 2 -> ABNORMAL_PUMP + 3 -> LOW_VOLTAGE + 4 -> ABNORMAL_TEMP + 5 -> NOT_USED + 12 -> PUMP_ERROR + 29 -> BY_LGS + else -> ERROR + } + } +} + +enum class InfusionModeResult { + BASAL, + TEMP_BASAL, + IMME_BOLUS, + EXTEND_IMME_BOLUS, + EXTEND_BOLUS, + ERROR; + + companion object { + + fun InfusionModeResult.commandToCode() = when (this) { + BASAL -> 1 + TEMP_BASAL -> 2 + IMME_BOLUS -> 3 + EXTEND_IMME_BOLUS -> 4 + EXTEND_BOLUS -> 5 + ERROR -> -1 + } + + fun Int.codeToInfusionModeCommand() = when (this) { + 1 -> BASAL + 2 -> TEMP_BASAL + 3 -> IMME_BOLUS + 4 -> EXTEND_IMME_BOLUS + 5 -> EXTEND_BOLUS + else -> ERROR + } + } +} + +enum class InfusionInfoResult { + BY_REQ, + BY_REMAIN_REQ, + BY_30MIN_RPT, + BY_RECONNECT, + ERROR; + + companion object { + + fun InfusionInfoResult.commandToCode() = when (this) { + BY_REQ -> 0 + BY_REMAIN_REQ -> 1 + BY_30MIN_RPT -> 2 + BY_RECONNECT -> 3 + ERROR -> -1 + } + + fun Int.codeToInfusionInfoCommand() = when (this) { + 0 -> BY_REQ + 1 -> BY_REMAIN_REQ + 2 -> BY_30MIN_RPT + 3 -> BY_RECONNECT + else -> ERROR + } + } +} + +enum class PumpStateResult { + READY, + PRIMING, + RUNNING, + ERROR; + + companion object { + + fun PumpStateResult.commandToCode() = when (this) { + READY -> 0 + PRIMING -> 1 + RUNNING -> 2 + ERROR -> 3 + } + + fun Int?.codeToPumpStateCommand() = when (this) { + 0 -> READY + 1 -> PRIMING + 2 -> RUNNING + else -> ERROR + } + } +} + +enum class WarningMessageResult { + INSULIN_DEFICIENCY, + EXPIRED, + LOW_VOLTAGE, + ABNORMAL_TEMP, + NOT_USED, + BLE_CONNECT, + NOT_STARTED_BASAL, + EXTENDED_EXPIRED, + PUMP_ERROR, + CANNULA_ERROR, + ERROR; + + companion object { + + fun WarningMessageResult.commandToCode() = when (this) { + INSULIN_DEFICIENCY -> 1 + EXPIRED -> 2 + LOW_VOLTAGE -> 3 + ABNORMAL_TEMP -> 4 + NOT_USED -> 5 + BLE_CONNECT -> 6 + NOT_STARTED_BASAL -> 7 + EXTENDED_EXPIRED -> 10 + PUMP_ERROR -> 12 + CANNULA_ERROR -> 99 + else -> -1 + } + + fun Int.codeToWarningMessageCommand() = when (this) { + 1 -> INSULIN_DEFICIENCY + 2 -> EXPIRED + 3 -> LOW_VOLTAGE + 4 -> ABNORMAL_TEMP + 5 -> NOT_USED + 6 -> BLE_CONNECT + 7 -> NOT_STARTED_BASAL + 10 -> EXTENDED_EXPIRED + 12 -> PUMP_ERROR + 99 -> CANNULA_ERROR + else -> ERROR + } + } +} + +enum class AlertMessageResult { + INSULIN_LOW, + EXPIRED_ALERT, + BATTERY_EXCEED, + ABNORMAL_TEMP, + NOT_USED, + BLE_CONNECT, + NOT_START_BASAL, + PUMP_STOP_FINISH, + EXTEND_EXPIRED, + ERROR; + + companion object { + + fun AlertMessageResult.commandToCode() = when (this) { + INSULIN_LOW -> 1 + EXPIRED_ALERT -> 2 + BATTERY_EXCEED -> 3 + ABNORMAL_TEMP -> 4 + NOT_USED -> 5 + BLE_CONNECT -> 6 + NOT_START_BASAL -> 7 + PUMP_STOP_FINISH -> 8 + EXTEND_EXPIRED -> 10 + else -> -1 + } + + fun Int.codeToAlertMessageCommand() = when (this) { + 1 -> INSULIN_LOW + 2 -> EXPIRED_ALERT + 3 -> BATTERY_EXCEED + 4 -> ABNORMAL_TEMP + 5 -> NOT_USED + 6 -> BLE_CONNECT + 7 -> NOT_START_BASAL + 8 -> PUMP_STOP_FINISH + 10 -> EXTEND_EXPIRED + else -> ERROR + } + } +} + +enum class NoticeMessageResult { + REMAIN_EXCEED, + EXPIRED_NOTICE, + INSPECTING, + SYNC_TIME, + GLUCOSE, + ERROR; + + companion object { + + fun NoticeMessageResult.commandToCode() = when (this) { + REMAIN_EXCEED -> 1 + EXPIRED_NOTICE -> 2 + INSPECTING -> 3 + SYNC_TIME -> 26 + GLUCOSE -> 27 + else -> -1 + } + + fun Int.codeToNoticeMessageCommand() = when (this) { + 1 -> REMAIN_EXCEED + 2 -> EXPIRED_NOTICE + 3 -> INSPECTING + 26 -> SYNC_TIME + 27 -> GLUCOSE + else -> ERROR + } + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoBtModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoBtModel.kt new file mode 100644 index 000000000000..0eb1de2e37c4 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoBtModel.kt @@ -0,0 +1,454 @@ +package app.aaps.pump.carelevo.domain.model.bt + +import app.aaps.pump.carelevo.domain.model.bt.InfusionInfoResult.Companion.codeToInfusionInfoCommand +import app.aaps.pump.carelevo.domain.model.bt.InfusionModeResult.Companion.codeToInfusionModeCommand +import app.aaps.pump.carelevo.domain.model.bt.PumpStateResult.Companion.codeToPumpStateCommand +import app.aaps.pump.carelevo.domain.model.bt.Result.Companion.codeToResultCommand +import app.aaps.pump.carelevo.domain.model.bt.SafetyCheckResult.Companion.codeToSafetyCheckCommand +import app.aaps.pump.carelevo.domain.model.bt.SetBasalProgramResult.Companion.codeToSetBasalProgramCommand +import app.aaps.pump.carelevo.domain.model.bt.SetBolusProgramResult.Companion.codeToSetBolusProgramCommand +import app.aaps.pump.carelevo.domain.model.bt.StopPumpResult.Companion.codeToStopPumpCommand +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType + +internal interface PatchResultModel + +data class ProtocolFailedAlarmMode(val alarmId: Long, val cause: Int) : PatchResultModel + +data class SetTimeResultModel( + val result: Result +) : PatchResultModel + +data class SafetyCheckResultModel( + val result: SafetyCheckResult, + val volume: Int, + val durationSeconds: Int +) : PatchResultModel + +data class AdditionalPrimingResultModel( + val result: Result +) : PatchResultModel + +data class SetThresholdNoticeResultModel( + val type: Int, + val result: Result +) : PatchResultModel + +data class SetInfusionThresholdResultModel( + val result: Result, + val type: Int +) : PatchResultModel + +data class SetBuzzModeResultModel( + val result: Result +) : PatchResultModel + +data class SetAlarmClearResultModel( + val result: Result, + val subId: Int, + val cause: Int +) : PatchResultModel + +data class CannulaInsertionResultModel( + val result: Result +) : PatchResultModel + +data class CannulaInsertionAckResultModel( + val result: Result +) : PatchResultModel + +data class ThresholdSetResultModel( + val result: Result +) : PatchResultModel + +data class ExtendPatchExpiryResultModel( + val result: Result +) : PatchResultModel + +data class StopPumpResultModel( + val result: Result +) : PatchResultModel + +data class ResumePumpResultModel( + val result: StopPumpResult, + val mode: InfusionModeResult, + val subId: Int +) : PatchResultModel + +data class StopPumpReportResultModel( + val result: StopPumpResult, + val mode: InfusionModeResult, + val subId: Int, + val infusedBolusInfusionAmount: Double, + val infusedBasalInfusionAmount: Double, + val temperature: Int +) : PatchResultModel + +data class InfusionInfoReportResultModel( + val subId: InfusionInfoResult, + val runningMinutes: Int, + val remains: Double, + val infusedTotalBasalAmount: Double, + val infusedTotalBolusAmount: Double, + val pumpState: PumpStateResult, + val mode: InfusionModeResult, + val infuseSetMinutes: Int, + val currentInfusedProgramVolume: Double, + val realInfusedTime: Int +) : PatchResultModel + +data class PatchInformationInquiryModel( + val result: Result, + val serialNum: String +) : PatchResultModel + +data class PatchInformationInquiryDetailModel( + val result: Result, + val firmwareVer: String, + val bootDateTime: String, + val modelName: String +) : PatchResultModel + +data class DiscardPatchResultModel( + val result: Result +) : PatchResultModel + +data class CheckBuzzResultModel( + val result: Result +) : PatchResultModel + +data class FinishPulseReportResultModel( + val mode: InfusionModeResult, + val pulseCnt: Int, + val totalNo: Int, + val count: Int, + val useMinutes: Int, + val remains: Double +) : PatchResultModel + +data class SetApplicationStatusResultModel( + val status: Int +) : PatchResultModel + +data class RetrieveAddressResultModel( + val address: String, + val checkSum: String +) : PatchResultModel + +class RecoveryPatchReportResultModel : PatchResultModel + +data class WarningReportResultModel( + val cause: AlarmCause, + val value: Int +) : PatchResultModel + +data class AlertReportResultModel( + val cause: AlarmCause, + val value: Int +) : PatchResultModel + +data class NoticeReportResultModel( + val cause: AlarmCause, + val value: Int +) : PatchResultModel + +data class AppAuthAckReportResultModel( + val value: Int +) : PatchResultModel + +data class AlertAlarmSetResultModel( + val result: Result +) : PatchResultModel + +data class AppAuthAckResultModel( + val result: Result +) : PatchResultModel + +data class AppAlarmClearResultModel( + val result: Result +) : PatchResultModel + +data class AppBuzzResultModel( + val result: Result +) : PatchResultModel + +internal fun createPatchResultModel(response: BtResponse): PatchResultModel? { + return if (isPatchProtocol(response.command) && response is SetTimeResponse) { + val value = response.result.codeToResultCommand() + SetTimeResultModel(value) + } else if (isPatchProtocol(response.command) && response is PatchInformationInquiryResponse) { + val value = response.result.codeToResultCommand() + PatchInformationInquiryModel( + value, + // response.productCL, + // response.productTY, + // response.productMO, + // response.processCO, + // response.manufactureYE, + // response.manufactureMO, + // response.manufactureDA, + // response.manufactureLO, + // response.manufactureNO + response.serialNum + ) + } else if (isPatchProtocol(response.command) && response is PatchInformationInquiryDetailResponse) { + val value = response.result.codeToResultCommand() + PatchInformationInquiryDetailModel(value, response.firmVersion, response.bootDateTime, response.modelName) + } else if (isPatchProtocol(response.command) && response is SafetyCheckResponse) { + val value = response.result.codeToSafetyCheckCommand() + SafetyCheckResultModel(value, response.volume, response.durationSeconds) + } else if (isPatchProtocol(response.command) && response is ThresholdSetResponse) { + val value = response.result.codeToResultCommand() + ThresholdSetResultModel(value) + } else if (isPatchProtocol(response.command) && response is CannulaInsertionResponse) { + val value = response.result.codeToResultCommand() + CannulaInsertionResultModel(value) + } else if (isPatchProtocol(response.command) && response is CannulaInsertionAckResponse) { + val value = response.result.codeToResultCommand() + CannulaInsertionAckResultModel(value) + } else if (isPatchProtocol(response.command) && response is SetInfusionThresholdResponse) { + val value = response.result.codeToResultCommand() + SetInfusionThresholdResultModel(value, response.type) + } else if (isPatchProtocol(response.command) && response is SetBuzzModeResponse) { + val value = response.result.codeToResultCommand() + SetBuzzModeResultModel(value) + } else if (isPatchProtocol(response.command) && response is ClearReportResponse) { + val value = response.result.codeToResultCommand() + SetAlarmClearResultModel(value, response.subId, response.cause) + } else if (isPatchProtocol(response.command) && response is SetExpiryExtendResponse) { + val value = response.result.codeToResultCommand() + ExtendPatchExpiryResultModel(value) + } else if (isPatchProtocol(response.command) && response is StopPumpResponse) { + val value = response.result.codeToResultCommand() + StopPumpResultModel(value) + } else if (isPatchProtocol(response.command) && response is ResumePumpResponse) { + val value = response.result.codeToStopPumpCommand() + val mode = response.mode.codeToInfusionModeCommand() + ResumePumpResultModel(value, mode, response.causeId) + } else if (isPatchProtocol(response.command) && response is StopPumpReportResponse) { + val value = response.result.codeToStopPumpCommand() + val mode = response.mode.codeToInfusionModeCommand() + StopPumpReportResultModel( + value, + mode, + response.causeId, + response.infusedBolusAmount, + response.unInfusedExtendBolusAmount, + response.temperature + ) + } else if (isPatchProtocol(response.command) && response is RetrieveInfusionStatusResponse) { + val subId = response.subId.codeToInfusionInfoCommand() + val pumpState = response.pumpState.codeToPumpStateCommand() + val mode = response.mode.codeToInfusionModeCommand() + InfusionInfoReportResultModel( + subId, + response.runningMinutes, + response.remains, + response.infusedTotalBasalAmount, + response.infusedTotalBolusAmount, + pumpState, + mode, + response.infusedSetMinutes, + response.currentInfusedProgramVolume, + response.realInfusedTime + ) + } else if (isPatchProtocol(response.command) && response is SetApplicationStatusResponse) { + SetApplicationStatusResultModel(response.status) + } else if (isPatchProtocol(response.command) && response is RetrieveAddressResponse) { + RetrieveAddressResultModel( + // response.value, + response.address, + response.checkSum + ) + } else if (isPatchProtocol(response.command) && response is SetDiscardResponse) { + val value = response.result.codeToResultCommand() + DiscardPatchResultModel(value) + } else if (isPatchProtocol(response.command) && response is RecoveryPatchResponse) { + RecoveryPatchReportResultModel() + } else if (isPatchProtocol(response.command) && response is WarningReportResponse) { + WarningReportResultModel( + //response.cause.codeToWarningMessageCommand(), + AlarmCause.fromTypeAndCode(AlarmType.WARNING, response.cause), + response.value + ) + } else if (isPatchProtocol(response.command) && response is AlertReportResponse) { + AlertReportResultModel( + AlarmCause.fromTypeAndCode(AlarmType.ALERT, response.cause), + response.value + ) + } else if (isPatchProtocol(response.command) && response is NoticeReportResponse) { + NoticeReportResultModel( + AlarmCause.fromTypeAndCode(AlarmType.NOTICE, response.cause), + response.value + ) + } else if (isPatchProtocol(response.command) && response is AppAuthRptResponse) { + AppAuthAckReportResultModel(response.value) + } else if (isPatchProtocol(response.command) && response is AdditionalPrimingResponse) { + AdditionalPrimingResultModel(response.result.codeToResultCommand()) + } else if (isPatchProtocol(response.command) && response is SetThresholdNoticeResponse) { + SetThresholdNoticeResultModel( + response.type, + response.result.codeToResultCommand() + ) + } else if (isPatchProtocol(response.command) && response is SetAlertAlarmModelResponse) { + AlertAlarmSetResultModel(response.result.codeToResultCommand()) + } else if (isPatchProtocol(response.command) && response is AppAuthAckRptResponse) { + AppAuthAckResultModel(response.result.codeToResultCommand()) + } else if (isPatchProtocol(response.command) && response is AppAlarmOffResponse) { + AppAlarmClearResultModel(response.result.codeToResultCommand()) + } else if (isPatchProtocol(response.command) && response is RetrieveOperationInfoResponse) { + RetrieveOperationInfoResultModel( + mode = response.mode, + pulseCnt = response.pulseCnt, + totalNo = response.totalNo, + count = response.count, + useMinutes = response.useMinutes, + remains = response.remains + ) + } else if (isPatchProtocol(response.command) && response is CheckBuzzResponse) { + AppBuzzResultModel(response.result.codeToResultCommand()) + } else { + null + } +} + +data class SetBasalProgramResultModel( + val result: SetBasalProgramResult +) : PatchResultModel + +data class SetBasalProgramAdditionalResultModel( + val result: SetBasalProgramResult +) : PatchResultModel + +data class UpdateBasalProgramResultModel( + val result: SetBasalProgramResult +) : PatchResultModel + +data class UpdateBasalProgramAdditionalResultModel( + val result: SetBasalProgramResult +) : PatchResultModel + +data class StartTempBasalProgramResultModel( + val result: SetBasalProgramResult +) : PatchResultModel + +data class CancelTempBasalProgramResultModel( + val result: Result +) : PatchResultModel + +class StartBasalProgramResultModel : PatchResultModel + +data class BasalInfusionResumeResultModel( + val segmentNo: Int, + val infusionSpeed: Double, + val infusionPeriod: Int, + val insulinRemains: Double +) : PatchResultModel + +internal fun createBasalResultModel(response: BtResponse): PatchResultModel? { + return if (isBasalProtocol(response.command) && response is SetBasalProgramResponse) { + val value = response.result.codeToSetBasalProgramCommand() + SetBasalProgramResultModel(value) + } else if (isBasalProtocol(response.command) && response is SetBasalProgramAdditionalResponse) { + val value = response.result.codeToSetBasalProgramCommand() + SetBasalProgramAdditionalResultModel(value) + } else if (isBasalProtocol(response.command) && response is UpdateBasalProgramResponse) { + val value = response.result.codeToSetBasalProgramCommand() + UpdateBasalProgramResultModel(value) + } else if (isBasalProtocol(response.command) && response is UpdateBasalProgramAdditionalResponse) { + val value = response.result.codeToSetBasalProgramCommand() + UpdateBasalProgramAdditionalResultModel(value) + } else if (isBasalProtocol(response.command) && response is StartTempBasalProgramResponse) { + val value = response.result.codeToSetBasalProgramCommand() + StartTempBasalProgramResultModel(value) + } else if (isBasalProtocol(response.command) && response is CancelTempBasalProgramResponse) { + val value = response.result.codeToResultCommand() + CancelTempBasalProgramResultModel(value) + } else if (isBasalProtocol(response.command) && response is StartBasalProgramResponse) { + StartBasalProgramResultModel() + } else if (isBasalProtocol(response.command) && response is ResumeBasalProgramResponse) { + BasalInfusionResumeResultModel( + response.segmentNo, + response.infusionSpeed, + response.infusionPeriod, + response.insulinRemains + ) + } else { + null + } +} + +data class StartImmeBolusResultModel( + val result: SetBolusProgramResult, + val actionId: Int, + val expectedTime: Int, + val remains: Double +) : PatchResultModel + +data class CancelImmeBolusResultModel( + val result: Result, + val remains: Double, + val infusedAmount: Double +) : PatchResultModel + +data class StartExtendBolusResultModel( + val result: SetBolusProgramResult, + val expectedTime: Int +) : PatchResultModel + +data class CancelExtendBolusResultModel( + val result: Result, + val infusedAmount: Double +) : PatchResultModel + +data class DelayExtendBolusReportResultModel( + val delayedAmount: Double, + val expectedTime: Int +) : PatchResultModel + +data class RetrieveOperationInfoResultModel( + val mode: Int, + val pulseCnt: Int, + val totalNo: Int, + val count: Int, + val useMinutes: Int, + val remains: Double +) : PatchResultModel + +internal fun createBolusResultModel(response: BtResponse): PatchResultModel? { + return if (isBolusProtocol(response.command) && response is StartImmeBolusResponse) { + val value = response.result.codeToSetBolusProgramCommand() + StartImmeBolusResultModel( + value, + response.actionId, + response.expectedTime, + response.remain + ) + } else if (isBolusProtocol(response.command) && response is CancelImmeBolusResponse) { + val value = response.result.codeToResultCommand() + CancelImmeBolusResultModel( + value, + response.remains, + response.infusedAmount + ) + } else if (isBolusProtocol(response.command) && response is StartExtendBolusResponse) { + val value = response.result.codeToSetBolusProgramCommand() + StartExtendBolusResultModel( + value, + response.expectedTime + ) + } else if (isBolusProtocol(response.command) && response is CancelExtendBolusResponse) { + val value = response.result.codeToResultCommand() + CancelExtendBolusResultModel( + value, + response.infusedAmount + ) + } else if (isBolusProtocol(response.command) && response is DelayExtendBolusResponse) { + DelayExtendBolusReportResultModel( + response.delayedAmount, + response.expectedTime + ) + } else { + null + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoBtRequest.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoBtRequest.kt new file mode 100644 index 000000000000..d831df96a2f6 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoBtRequest.kt @@ -0,0 +1,133 @@ +package app.aaps.pump.carelevo.domain.model.bt + +import app.aaps.pump.carelevo.domain.model.RepositoryRequest +import app.aaps.pump.carelevo.domain.model.basal.CarelevoBasalSegment + +internal interface TempBasalRequest + +data class SetTimeRequest( + val dateTime: String, + val volume: Int, + val subId: Int, + val aidMode: Int +) : RepositoryRequest + +data class SetBuzzModeRequest( + val isOn: Boolean +) : RepositoryRequest + +data class ThresholdSetRequest( + val remains: Int, + val expiryHour: Int, + val maxBasalSpeed: Double, + val maxBolusDose: Double, + val buzzUse: Boolean +) : RepositoryRequest + +data class SetAlertAlarmModeRequest( + val mode: Int +) : RepositoryRequest + +data class SetExpiryExtendRequest( + val extendHour: Int +) : RepositoryRequest + +data class StopPumpRequest( + val expectMinutes: Int, + val subId: Int +) : RepositoryRequest + +data class ResumePumpRequest( + val mode: Int, + val causeId: Int +) : RepositoryRequest + +data class StopPumpRptAckRequest( + val subId: Int +) : RepositoryRequest + +data class SetThresholdInfusionMaxSpeedRequest( + val value: Double +) : RepositoryRequest + +data class SetThresholdNoticeRequest( + val value: Int, + val type: Int +) : RepositoryRequest + +data class SetThresholdInfusionMaxDoseRequest( + val value: Double +) : RepositoryRequest + +data class RetrieveInfusionStatusRequest( + val inquiryType: Int +) : RepositoryRequest + +data class SetApplicationStatusRequest( + val isBackground: Boolean, + val infusionStopHour: Int +) : RepositoryRequest + +data class SetAlarmClearRequest( + val alarmType: Int, + val causeId: Int +) : RepositoryRequest + +data class SetInitializeRequest( + val mode: Boolean +) : RepositoryRequest + +data class RetrieveAddressRequest( + val key: Byte +) : RepositoryRequest + +data class SetBasalProgramRequest( + val totalSegmentCnt: Int, + val segmentList: List +) : RepositoryRequest + +data class SetBasalProgramAdditionalRequest( + val msgNumber: Int, + val segmentCnt: Int, + val segmentList: List +) : RepositoryRequest + +data class SetBasalProgramRequestV2( + val seqNo: Int, + val segmentList: List +) : RepositoryRequest + +data class UpdateBasalProgramRequest( + val totalBasalSegmentCnt: Int, + val segmentList: List +) : RepositoryRequest + +data class UpdateBasalProgramAdditionalRequest( + val msgNumber: Int, + val segmentCnt: Int, + val segmentList: List +) : RepositoryRequest + +data class StartTempBasalProgramByUnitRequest( + val infusionUnit: Double, + val infusionHour: Int, + val infusionMin: Int +) : RepositoryRequest + +data class StartTempBasalProgramByPercentRequest( + val infusionPercent: Int, + val infusionHour: Int, + val infusionMin: Int +) : RepositoryRequest + +data class StartImmeBolusRequest( + val actionId: Int, + val volume: Double, +) : RepositoryRequest + +data class StartExtendBolusRequest( + val volume: Double, + val speed: Double, + val hour: Int, + val min: Int +) : RepositoryRequest \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoBtResponse.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoBtResponse.kt new file mode 100644 index 000000000000..e29ccfca70c1 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoBtResponse.kt @@ -0,0 +1,317 @@ +package app.aaps.pump.carelevo.domain.model.bt + +import app.aaps.pump.carelevo.domain.model.RepositoryResponse + +interface BtResponse : RepositoryResponse { + + val timestamp: Long + val command: Int +} + +data class SetTimeResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse + +data class SafetyCheckResponse( + override val timestamp: Long, + override val command: Int, + val result: Int, + val volume: Int, + val durationSeconds: Int +) : BtResponse + +data class AdditionalPrimingResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse + +data class SetAlertAlarmModelResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse + +data class SetThresholdNoticeResponse( + override val timestamp: Long, + override val command: Int, + val result: Int, + val type: Int +) : BtResponse + +data class SetInfusionThresholdResponse( + override val timestamp: Long, + override val command: Int, + val type: Int, + val result: Int +) : BtResponse + +data class SetBuzzModeResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse + +data class CannulaInsertionResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse + +data class CannulaInsertionAckResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse + +data class ThresholdSetResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse + +data class SetExpiryExtendResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse + +data class StopPumpResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse + +data class ResumePumpResponse( + override val timestamp: Long, + override val command: Int, + val result: Int, + val mode: Int, + val causeId: Int +) : BtResponse + +data class StopPumpReportResponse( + override val timestamp: Long, + override val command: Int, + val result: Int, + val mode: Int, + val causeId: Int, + val infusedBolusAmount: Double, + val unInfusedExtendBolusAmount: Double, + val temperature: Int +) : BtResponse + +data class PatchInformationInquiryResponse( + override val timestamp: Long, + override val command: Int, + val result: Int, + val serialNum: String +) : BtResponse + +data class PatchInformationInquiryDetailResponse( + override val timestamp: Long, + override val command: Int, + val result: Int, + val firmVersion: String, + val bootDateTime: String, + val modelName: String +) : BtResponse + +data class RetrieveInfusionStatusResponse( + override val timestamp: Long, + override val command: Int, + val subId: Int, + val runningMinutes: Int, + val remains: Double, + val infusedTotalBasalAmount: Double, + val infusedTotalBolusAmount: Double, + val pumpState: Int, + val mode: Int, + val infusedSetMinutes: Int, + val currentInfusedProgramVolume: Double, + val realInfusedTime: Int +) : BtResponse + +data class SetApplicationStatusResponse( + override val timestamp: Long, + override val command: Int, + val status: Int +) : BtResponse + +data class SetInitializeResponse( + override val timestamp: Long, + override val command: Int, + val mode: Int +) : BtResponse + +data class SetDiscardResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse + +data class CheckBuzzResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse + +data class RetrieveOperationInfoResponse( + override val timestamp: Long, + override val command: Int, + val mode: Int, + val pulseCnt: Int, + val totalNo: Int, + val count: Int, + val useMinutes: Int, + val remains: Double +) : BtResponse + +data class RetrieveAddressResponse( + override val timestamp: Long, + override val command: Int, + val address: String, + val checkSum: String, +) : BtResponse + +data class WarningReportResponse( + override val timestamp: Long, + override val command: Int, + val cause: Int, + val value: Int, +) : BtResponse + +data class AlertReportResponse( + override val timestamp: Long, + override val command: Int, + val cause: Int, + val value: Int +) : BtResponse + +data class NoticeReportResponse( + override val timestamp: Long, + override val command: Int, + val cause: Int, + val value: Int +) : BtResponse + +data class ClearReportResponse( + override val timestamp: Long, + override val command: Int, + val result: Int, + val subId: Int, + val cause: Int +) : BtResponse + +data class RecoveryPatchResponse( + override val timestamp: Long, + override val command: Int, +) : BtResponse + +data class AppAuthRptResponse( + override val timestamp: Long, + override val command: Int, + val value: Int +) : BtResponse + +data class AppAuthAckRptResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse + +data class SetBasalProgramResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse + +data class SetBasalProgramAdditionalResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse + +data class UpdateBasalProgramResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse + +data class UpdateBasalProgramAdditionalResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse + +data class StartTempBasalProgramResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse + +data class ResumeBasalProgramResponse( + override val timestamp: Long, + override val command: Int, + val segmentNo: Int, + val infusionSpeed: Double, + val infusionPeriod: Int, + val insulinRemains: Double +) : BtResponse + +data class StartBasalProgramResponse( + override val timestamp: Long, + override val command: Int +) : BtResponse + +data class CancelTempBasalProgramResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse + +data class StartImmeBolusResponse( + override val timestamp: Long, + override val command: Int, + val result: Int, + val actionId: Int, + val expectedTime: Int, + val remain: Double +) : BtResponse + +data class CancelImmeBolusResponse( + override val timestamp: Long, + override val command: Int, + val result: Int, + val remains: Double, + val infusedAmount: Double +) : BtResponse + +data class StartExtendBolusResponse( + override val timestamp: Long, + override val command: Int, + val result: Int, + val expectedTime: Int +) : BtResponse + +data class CancelExtendBolusResponse( + override val timestamp: Long, + override val command: Int, + val result: Int, + val infusedAmount: Double +) : BtResponse + +data class DelayExtendBolusResponse( + override val timestamp: Long, + override val command: Int, + val delayedAmount: Double, + val expectedTime: Int +) : BtResponse + +data class AppAlarmOffResponse( + override val timestamp: Long, + override val command: Int, + val result: Int +) : BtResponse \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoProtocolChecker.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoProtocolChecker.kt new file mode 100644 index 000000000000..7dcf48d89002 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoProtocolChecker.kt @@ -0,0 +1,290 @@ +package app.aaps.pump.carelevo.domain.model.bt + +internal fun isPatchProtocol(command: Int) = when (command) { + 0x11 -> true + 0x71 -> true + 0x12 -> true + 0x72 -> true + 0x13 -> false + 0x73 -> false + 0x14 -> false + 0x74 -> false + 0x15 -> true + 0x75 -> true + 0x16 -> true + 0x76 -> true + 0x17 -> true + 0x77 -> true + 0x18 -> true + 0x78 -> true + 0x19 -> true + 0x79 -> true + 0x1A -> true + 0x7A -> true + 0x1B -> true + 0x7B -> true + 0x1C -> true + 0x7C -> true + 0x21 -> false + 0x81 -> false + 0x22 -> false + 0x82 -> false + 0x23 -> false + 0x83 -> false + 0x24 -> false + 0x84 -> false + 0x25 -> false + 0x85 -> false + 0x26 -> true + 0x86 -> true + 0x27 -> true + 0x87 -> true + 0x88 -> false + 0x29 -> false + 0x89 -> false + 0x2A -> true + 0x2B -> false + 0x8B -> false + 0x2C -> false + 0x8C -> false + 0x2D -> false + 0x8D -> false + 0x31 -> true + 0x91 -> true + 0x33 -> true + 0x93 -> true + 0x94 -> true + 0x35 -> true + 0x95 -> true + 0x36 -> true + 0x96 -> true + 0x37 -> true + 0x97 -> true + 0x38 -> true + 0x98 -> true + 0x39 -> true + 0x99 -> true + 0x3A -> true + 0x9A -> true + 0x3D -> true + 0x9D -> true + 0x9E -> true + 0x3B -> true + 0x9B -> true + 0x3F -> true + 0x9F -> true + 0x4D -> true + 0xA1 -> true + 0xA2 -> true + 0xA3 -> true + 0x47 -> true + 0xA7 -> true + + 0x9C -> false + + 0x4A -> true + 0xBA -> true + 0x4B -> true + + 0x1D -> true + 0x7D -> true + + 0x48 -> true + 0xA8 -> true + + 0xBB -> true + + else -> false +} + +internal fun isBasalProtocol(command: Int) = when (command) { + 0x11 -> false + 0x71 -> false + 0x12 -> false + 0x72 -> false + 0x13 -> true + 0x73 -> true + 0x14 -> true + 0x74 -> true + 0x15 -> false + 0x75 -> false + 0x16 -> false + 0x76 -> false + 0x17 -> false + 0x77 -> false + 0x18 -> false + 0x78 -> false + 0x19 -> false + 0x79 -> false + 0x1A -> false + 0x7A -> false + 0x1B -> false + 0x7B -> false + 0x1C -> false + 0x7C -> false + 0x21 -> true + 0x81 -> true + 0x22 -> true + 0x82 -> true + 0x23 -> true + 0x83 -> true + 0x24 -> false + 0x84 -> false + 0x25 -> false + 0x85 -> false + 0x26 -> false + 0x86 -> false + 0x27 -> false + 0x87 -> false + 0x88 -> true + 0x29 -> false + 0x89 -> false + 0x2A -> false + 0x2B -> true + 0x8B -> true + 0x2C -> false + 0x8C -> false + 0x2D -> true + 0x8D -> true + 0x31 -> false + 0x91 -> false + 0x33 -> false + 0x93 -> false + 0x94 -> false + 0x35 -> false + 0x95 -> false + 0x36 -> false + 0x96 -> false + 0x37 -> false + 0x97 -> false + 0x38 -> false + 0x98 -> false + 0x39 -> false + 0x99 -> false + 0x3A -> false + 0x9A -> false + 0x3D -> false + 0x9D -> false + 0x9E -> false + 0x3B -> false + 0x9B -> false + 0x3F -> false + 0x9F -> false + 0x4D -> false + 0xA1 -> false + 0xA2 -> false + 0xA3 -> false + 0x47 -> false + 0xA7 -> false + + 0x9C -> false + 0x4D -> false + + 0x4A -> false + 0xBA -> false + 0x4B -> false + + 0x1D -> false + 0x7D -> false + + 0x48 -> false + 0xA8 -> false + + else -> false +} + +internal fun isBolusProtocol(command: Int) = when (command) { + 0x11 -> false + 0x71 -> false + 0x12 -> false + 0x72 -> false + 0x13 -> false + 0x73 -> false + 0x14 -> false + 0x74 -> false + 0x15 -> false + 0x75 -> false + 0x16 -> false + 0x76 -> false + 0x17 -> false + 0x77 -> false + 0x18 -> false + 0x78 -> false + 0x19 -> false + 0x79 -> false + 0x1A -> false + 0x7A -> false + 0x1B -> false + 0x7B -> false + 0x1C -> false + 0x7C -> false + 0x21 -> false + 0x81 -> false + 0x22 -> false + 0x82 -> false + 0x23 -> false + 0x83 -> false + 0x24 -> true + 0x84 -> true + 0x25 -> true + 0x85 -> true + 0x26 -> false + 0x86 -> false + 0x27 -> false + 0x87 -> false + 0x88 -> false + 0x29 -> true + 0x89 -> true + 0x2A -> false + 0x2B -> false + 0x8B -> false + 0x2C -> true + 0x8C -> true + 0x2D -> false + 0x8D -> false + 0x31 -> false + 0x91 -> false + 0x33 -> false + 0x93 -> false + 0x94 -> false + 0x35 -> false + 0x95 -> false + 0x36 -> false + 0x96 -> false + 0x37 -> false + 0x97 -> false + 0x38 -> false + 0x98 -> false + 0x39 -> false + 0x99 -> false + 0x3A -> false + 0x9A -> false + 0x3D -> false + 0x9D -> false + 0x9E -> false + 0x3B -> false + 0x9B -> false + 0x3F -> false + 0x9F -> false + 0x4D -> false + 0xA1 -> false + 0xA2 -> false + 0xA3 -> false + 0x47 -> false + 0xA7 -> false + + 0x9C -> false + 0x4D -> false + + 0x4A -> false + 0xBA -> false + 0x4B -> false + + 0x1D -> false + 0x7D -> false + + 0x48 -> false + 0xA8 -> false + + else -> false +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/infusion/CarelevoInfusionInfoDomainModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/infusion/CarelevoInfusionInfoDomainModel.kt new file mode 100644 index 000000000000..aa532abd2a8e --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/infusion/CarelevoInfusionInfoDomainModel.kt @@ -0,0 +1,89 @@ +package app.aaps.pump.carelevo.domain.model.infusion + +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import org.joda.time.DateTime + +data class CarelevoInfusionInfoDomainModel( + val basalInfusionInfo: CarelevoBasalInfusionInfoDomainModel? = null, + val tempBasalInfusionInfo: CarelevoTempBasalInfusionInfoDomainModel? = null, + val immeBolusInfusionInfo: CarelevoImmeBolusInfusionInfoDomainModel? = null, + val extendBolusInfusionInfo: CarelevoExtendBolusInfusionInfoDomainModel? = null +) : CarelevoUseCaseResponse + +/** + * The patch `mode` wire values persisted into `CarelevoPatchInfoDomainModel.mode` (vendor protocol; + * 4 is unused by this driver). + */ +object CarelevoPatchMode { + + const val BASAL_STOPPED = 0 + const val BASAL_RUNNING = 1 + const val TEMP_BASAL = 2 + const val IMME_BOLUS = 3 + const val EXTEND_BOLUS = 5 +} + +/** + * Recompute the patch `mode` from what is still running, highest-priority active infusion first + * (extended bolus > immediate bolus > temp basal > basal running/stopped). Returns null when NO + * infusion record exists at all — callers decide whether that is an error (`?: throw`, the + * mid-therapy persists) or means the patch is stopped (`?: BASAL_STOPPED`, the delete/discard + * path). Single shared mode derivation used by the cancel/finish use cases. + */ +fun CarelevoInfusionInfoDomainModel.derivePatchMode(): Int? = when { + extendBolusInfusionInfo != null -> CarelevoPatchMode.EXTEND_BOLUS + immeBolusInfusionInfo != null -> CarelevoPatchMode.IMME_BOLUS + tempBasalInfusionInfo != null -> CarelevoPatchMode.TEMP_BASAL + basalInfusionInfo != null -> if (basalInfusionInfo.isStop) CarelevoPatchMode.BASAL_STOPPED else CarelevoPatchMode.BASAL_RUNNING + else -> null +} + +data class CarelevoBasalSegmentInfusionInfoDomainModel( + val createdAt: DateTime = DateTime.now(), + val updatedAt: DateTime = DateTime.now(), + val startTime: Int, + val endTime: Int, + val speed: Double +) + +data class CarelevoBasalInfusionInfoDomainModel( + val infusionId: String, + val address: String, + val mode: Int, + val createdAt: DateTime = DateTime.now(), + val updatedAt: DateTime = DateTime.now(), + val segments: List, + val isStop: Boolean +) + +data class CarelevoTempBasalInfusionInfoDomainModel( + val infusionId: String, + val address: String, + val mode: Int, + val createdAt: DateTime = DateTime.now(), + val updatedAt: DateTime = DateTime.now(), + val percent: Int? = null, + val speed: Double? = null, + val infusionDurationMin: Int? = null +) + +data class CarelevoImmeBolusInfusionInfoDomainModel( + val infusionId: String, + val address: String, + val mode: Int, + val createdAt: DateTime = DateTime.now(), + val updatedAt: DateTime = DateTime.now(), + val volume: Double? = null, + val infusionDurationSeconds: Int? = null +) + +data class CarelevoExtendBolusInfusionInfoDomainModel( + val infusionId: String, + val address: String, + val mode: Int, + val createdAt: DateTime = DateTime.now(), + val updatedAt: DateTime = DateTime.now(), + val volume: Double? = null, + val speed: Double? = null, + val infusionDurationMin: Int? = null +) \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/patch/CarelevoPatchInfoDomainModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/patch/CarelevoPatchInfoDomainModel.kt new file mode 100644 index 000000000000..103a54cfde02 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/patch/CarelevoPatchInfoDomainModel.kt @@ -0,0 +1,45 @@ +package app.aaps.pump.carelevo.domain.model.patch + +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import org.joda.time.DateTime + +data class CarelevoPatchInfoDomainModel( + val address: String, + val createdAt: DateTime = DateTime.now(), + val updatedAt: DateTime = DateTime.now(), + val manufactureNumber: String? = null, + val firmwareVersion: String? = null, + val bootDateTime: String? = null, + val bootDateTimeUtcMillis: Long? = null, + val modelName: String? = null, + val insulinAmount: Int? = null, + val insulinRemain: Double? = null, + val thresholdInsulinRemain: Int? = null, + val thresholdExpiry: Int? = null, + val thresholdMaxBasalSpeed: Double? = null, + val thresholdMaxBolusDose: Double? = null, + val checkSafety: Boolean? = null, + val checkNeedle: Boolean? = null, + val needleFailedCount: Int? = null, + val isConnected: Boolean? = null, + val needDiscard: Boolean? = null, + val isDiscard: Boolean? = null, + val isExtended: Boolean? = null, + val isValid: Boolean? = null, + val isStopped: Boolean? = null, + val stopMinutes: Int? = null, + val stopMode: Int? = null, + val isForceStopped: Boolean? = null, + val runningMinutes: Int? = null, + val infusedTotalBasalAmount: Double? = null, + val infusedTotalBolusAmount: Double? = null, + val pumpState: Int? = null, + val mode: Int? = null, + val bolusActionSeq: Int? = null +) : CarelevoUseCaseResponse + +data object NeedleCheckSuccess : CarelevoUseCaseResponse + +data class NeedleCheckFailed( + val failedCount: Int +) : CarelevoUseCaseResponse diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/result/CarelevoResultModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/result/CarelevoResultModel.kt new file mode 100644 index 000000000000..4be048a75645 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/result/CarelevoResultModel.kt @@ -0,0 +1,6 @@ +package app.aaps.pump.carelevo.domain.model.result + +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse + +object ResultSuccess : CarelevoUseCaseResponse +object ResultFailed : CarelevoUseCaseResponse \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/userSetting/CarelevoUserSettingInfoDomainModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/userSetting/CarelevoUserSettingInfoDomainModel.kt new file mode 100644 index 000000000000..4afa353d2b24 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/model/userSetting/CarelevoUserSettingInfoDomainModel.kt @@ -0,0 +1,15 @@ +package app.aaps.pump.carelevo.domain.model.userSetting + +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import org.joda.time.DateTime + +data class CarelevoUserSettingInfoDomainModel( + val createdAt: DateTime = DateTime.now(), + val updatedAt: DateTime = DateTime.now(), + val lowInsulinNoticeAmount: Int? = null, + val maxBasalSpeed: Double? = null, + val maxBolusDose: Double? = null, + val needLowInsulinNoticeAmountSyncPatch: Boolean = false, + val needMaxBasalSpeedSyncPatch: Boolean = false, + val needMaxBolusDoseSyncPatch: Boolean = false +) : CarelevoUseCaseResponse \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/repository/CarelevoAlarmInfoRepository.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/repository/CarelevoAlarmInfoRepository.kt new file mode 100644 index 000000000000..21da592d5b0d --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/repository/CarelevoAlarmInfoRepository.kt @@ -0,0 +1,21 @@ +package app.aaps.pump.carelevo.domain.repository + +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import java.util.Optional + +interface CarelevoAlarmInfoRepository { + + fun observeAlarms(): Observable>> + + /** One-shot read of ALL stored alarms. Everything stored is active — acknowledging removes. */ + fun getAlarmsOnce(): Single>> + fun setAlarms(list: List): Completable + fun upsertAlarm(alarm: CarelevoAlarmInfo): Completable + + /** Remove one alarm from the store (= acknowledge; there is no acknowledged-alarm history). */ + fun removeAlarm(alarmId: String): Completable + fun clearAlarms(): Completable +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/repository/CarelevoInfusionInfoRepository.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/repository/CarelevoInfusionInfoRepository.kt new file mode 100644 index 000000000000..77e924738b66 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/repository/CarelevoInfusionInfoRepository.kt @@ -0,0 +1,26 @@ +package app.aaps.pump.carelevo.domain.repository + +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoExtendBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoImmeBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoTempBasalInfusionInfoDomainModel +import io.reactivex.rxjava3.core.Observable +import java.util.Optional + +interface CarelevoInfusionInfoRepository { + + fun getInfusionInfo(): Observable> + fun getInfusionInfoBySync(): CarelevoInfusionInfoDomainModel? + + fun updateBasalInfusionInfo(info: CarelevoBasalInfusionInfoDomainModel): Boolean + fun updateTempBasalInfusionInfo(info: CarelevoTempBasalInfusionInfoDomainModel): Boolean + fun updateImmeBolusInfusionInfo(info: CarelevoImmeBolusInfusionInfoDomainModel): Boolean + fun updateExtendBolusInfusionInfo(info: CarelevoExtendBolusInfusionInfoDomainModel): Boolean + + fun deleteBasalInfusionInfo(): Boolean + fun deleteTempBasalInfusionInfo(): Boolean + fun deleteImmeBolusInfusionInfo(): Boolean + fun deleteExtendBolusInfusionInfo(): Boolean + fun deleteInfusionInfo(): Boolean +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/repository/CarelevoPatchInfoRepository.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/repository/CarelevoPatchInfoRepository.kt new file mode 100644 index 000000000000..410da15491e0 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/repository/CarelevoPatchInfoRepository.kt @@ -0,0 +1,14 @@ +package app.aaps.pump.carelevo.domain.repository + +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import io.reactivex.rxjava3.core.Observable +import java.util.Optional + +interface CarelevoPatchInfoRepository { + + fun getPatchInfo(): Observable> + fun getPatchInfoBySync(): CarelevoPatchInfoDomainModel? + + fun updatePatchInfo(info: CarelevoPatchInfoDomainModel): Boolean + fun deletePatchInfo(): Boolean +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/repository/CarelevoUserSettingInfoRepository.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/repository/CarelevoUserSettingInfoRepository.kt new file mode 100644 index 000000000000..8a19e9274323 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/repository/CarelevoUserSettingInfoRepository.kt @@ -0,0 +1,14 @@ +package app.aaps.pump.carelevo.domain.repository + +import app.aaps.pump.carelevo.domain.model.userSetting.CarelevoUserSettingInfoDomainModel +import io.reactivex.rxjava3.core.Observable +import java.util.Optional + +interface CarelevoUserSettingInfoRepository { + + fun getUserSettingInfo(): Observable> + fun getUserSettingInfoBySync(): CarelevoUserSettingInfoDomainModel? + + fun updateUserSettingInfo(info: CarelevoUserSettingInfoDomainModel): Boolean + fun deleteUserSettingInfo(): Boolean +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/type/AlarmType.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/type/AlarmType.kt new file mode 100644 index 000000000000..2344a4c84a66 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/type/AlarmType.kt @@ -0,0 +1,88 @@ +package app.aaps.pump.carelevo.domain.type + +/** + * Pump alarm severity tier as reported on the wire (`code` 0/1/2). + * + * Tier semantics, derived from the paired cause codes in [AlarmCause]: ALERT is the ESCALATION of + * WARNING for the same condition (WARNING_LOW_INSULIN(0x01) → ALERT_OUT_OF_INSULIN(0x01), + * WARNING_PATCH_EXPIRED_PHASE_1(0x02) → ALERT_PATCH_EXPIRED_PHASE_2(0x02), BLE_NOT_CONNECTED 0x06 + * exists in both). NOTICE is informational. + */ +enum class AlarmType(val code: Int) { + WARNING(0), + ALERT(1), + NOTICE(2), + UNKNOWN_TYPE(3); + + companion object { + + fun fromCode(code: Int?): AlarmType { + return entries.find { it.code == code } ?: UNKNOWN_TYPE + } + + fun fromAlarmType(type: AlarmType): Int { + return type.code + } + + /** + * True for the tiers that must take the STRONGEST surfacing path (global alarm sound + + * full-screen, never a silent banner): both pump-fault tiers, WARNING and ALERT. + * + * DEVICE-VERIFY: confirm against vendor protocol docs that no ALERT cause is intentionally + * low-priority. + */ + fun AlarmType.isCritical(): Boolean = + this == WARNING || this == ALERT + } +} + +enum class AlarmCause(val alarmType: AlarmType, val code: Int?, val value: Int? = null) { + ALARM_WARNING_LOW_INSULIN(AlarmType.WARNING, 0x01), + ALARM_WARNING_PATCH_EXPIRED_PHASE_1(AlarmType.WARNING, 0x02), + ALARM_WARNING_LOW_BATTERY(AlarmType.WARNING, 0x03), + ALARM_WARNING_INVALID_TEMPERATURE(AlarmType.WARNING, 0x04), + ALARM_WARNING_NOT_USED_APP_AUTO_OFF(AlarmType.WARNING, 0x05), + ALARM_WARNING_BLE_NOT_CONNECTED(AlarmType.WARNING, 0x06), + ALARM_WARNING_INCOMPLETE_PATCH_SETTING(AlarmType.WARNING, 0x07), + ALARM_WARNING_SELF_DIAGNOSIS_FAILED(AlarmType.WARNING, 0x09), + ALARM_WARNING_PATCH_EXPIRED(AlarmType.WARNING, 0x0a), + ALARM_WARNING_PATCH_ERROR(AlarmType.WARNING, 0x0b), + ALARM_WARNING_PUMP_CLOGGED(AlarmType.WARNING, 0x0c), + ALARM_WARNING_NEEDLE_INSERTION_ERROR(AlarmType.WARNING, 99), + + ALARM_ALERT_OUT_OF_INSULIN(AlarmType.ALERT, 0x01), + ALARM_ALERT_PATCH_EXPIRED_PHASE_2(AlarmType.ALERT, 0x02), + ALARM_ALERT_LOW_BATTERY(AlarmType.ALERT, 0x03), + ALARM_ALERT_INVALID_TEMPERATURE(AlarmType.ALERT, 0x04), + ALARM_ALERT_APP_NO_USE(AlarmType.ALERT, 0x05), + ALARM_ALERT_BLE_NOT_CONNECTED(AlarmType.ALERT, 0x06), + ALARM_ALERT_PATCH_APPLICATION_INCOMPLETE(AlarmType.ALERT, 0x07), + ALARM_ALERT_RESUME_INSULIN_DELIVERY_TIMEOUT(AlarmType.ALERT, 0x08), + ALARM_ALERT_PATCH_EXPIRED_PHASE_1(AlarmType.ALERT, 0x0a), + ALARM_ALERT_BLUETOOTH_OFF(AlarmType.ALERT, 97), + + ALARM_NOTICE_LOW_INSULIN(AlarmType.NOTICE, 0x01), + ALARM_NOTICE_PATCH_EXPIRED(AlarmType.NOTICE, 0x02), + ALARM_NOTICE_ATTACH_PATCH_CHECK(AlarmType.NOTICE, 0x09), + ALARM_NOTICE_TIME_ZONE_CHANGED(AlarmType.NOTICE, 96), + ALARM_NOTICE_BG_CHECK(AlarmType.NOTICE, 98), + ALARM_NOTICE_LGS_START(AlarmType.NOTICE, 99), + ALARM_NOTICE_LGS_FINISHED_DISCONNECTED_PATCH_OR_CGM(AlarmType.NOTICE, 100, 1), + ALARM_NOTICE_LGS_FINISHED_PAUSE_LGS(AlarmType.NOTICE, 100, 2), + ALARM_NOTICE_LGS_FINISHED_TIME_OVER(AlarmType.NOTICE, 100, 3), + ALARM_NOTICE_LGS_FINISHED_OFF_LGS(AlarmType.NOTICE, 100, 4), + ALARM_NOTICE_LGS_FINISHED_HIGH_BG(AlarmType.NOTICE, 100, 5), + ALARM_NOTICE_LGS_FINISHED_UNKNOWN(AlarmType.NOTICE, 100), + ALARM_NOTICE_LGS_NOT_WORKING(AlarmType.NOTICE, 101), + + ALARM_UNKNOWN(AlarmType.UNKNOWN_TYPE, null); + + companion object { + + fun fromTypeAndCode(alarmType: AlarmType, code: Int?, value: Int? = null): AlarmCause { + return entries.find { + it.alarmType == alarmType && it.code == code && it.value == value + } ?: ALARM_UNKNOWN + } + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/type/SafetyProgress.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/type/SafetyProgress.kt new file mode 100644 index 000000000000..71b8ca8da1a0 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/type/SafetyProgress.kt @@ -0,0 +1,9 @@ +package app.aaps.pump.carelevo.domain.type + +import app.aaps.pump.carelevo.domain.model.bt.SafetyCheckResultModel + +sealed class SafetyProgress { + data class Progress(val timeoutSec: Long) : SafetyProgress() + data class Success(val result: SafetyCheckResultModel) : SafetyProgress() + data class Error(val throwable: Throwable) : SafetyProgress() +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/CarelevoUseCaseRequest.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/CarelevoUseCaseRequest.kt new file mode 100644 index 000000000000..528d1076d20f --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/CarelevoUseCaseRequest.kt @@ -0,0 +1,3 @@ +package app.aaps.pump.carelevo.domain.usecase + +interface CarelevoUseCaseRequest \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/CarelevoUseCaseResponse.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/CarelevoUseCaseResponse.kt new file mode 100644 index 000000000000..3f61ebe909a0 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/CarelevoUseCaseResponse.kt @@ -0,0 +1,3 @@ +package app.aaps.pump.carelevo.domain.usecase + +interface CarelevoUseCaseResponse \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/AlarmClearPatchDiscardUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/AlarmClearPatchDiscardUseCase.kt new file mode 100644 index 000000000000..9122fecea0bd --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/AlarmClearPatchDiscardUseCase.kt @@ -0,0 +1,34 @@ +package app.aaps.pump.carelevo.domain.usecase.alarm + +import app.aaps.pump.carelevo.domain.repository.CarelevoAlarmInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoUserSettingInfoRepository +import org.joda.time.DateTime + +class AlarmClearPatchDiscardUseCase( + private val alarmRepository: CarelevoAlarmInfoRepository, + private val patchInfoRepository: CarelevoPatchInfoRepository, + private val userSettingInfoRepository: CarelevoUserSettingInfoRepository, + private val infusionInfoRepository: CarelevoInfusionInfoRepository +) { + + /** + * Persist the alarm-triggered discard outcome after a successful discard write: ack the alarm, clear + * the user-setting sync flags, and delete the infusion + patch records. Returns false with no + * user-setting record or if any write fails. + */ + fun persistAlarmDiscarded(alarmId: String): Boolean = runCatching { + alarmRepository.removeAlarm(alarmId).blockingAwait() + + val userSettingInfo = userSettingInfoRepository.getUserSettingInfoBySync() + ?: throw NullPointerException("user setting info must be not null") + require( + userSettingInfoRepository.updateUserSettingInfo( + userSettingInfo.copy(updatedAt = DateTime.now(), needMaxBolusDoseSyncPatch = false, needMaxBasalSpeedSyncPatch = false, needLowInsulinNoticeAmountSyncPatch = false) + ) + ) { "update user setting info is failed" } + require(infusionInfoRepository.deleteInfusionInfo()) { "delete infusion info is failed" } + require(patchInfoRepository.deletePatchInfo()) { "delete patch info is failed" } + }.isSuccess +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/AlarmClearRequestUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/AlarmClearRequestUseCase.kt new file mode 100644 index 000000000000..b371c2df5dbb --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/AlarmClearRequestUseCase.kt @@ -0,0 +1,34 @@ +package app.aaps.pump.carelevo.domain.usecase.alarm + +import app.aaps.pump.carelevo.domain.repository.CarelevoAlarmInfoRepository +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType + +class AlarmClearRequestUseCase( + private val alarmRepository: CarelevoAlarmInfoRepository +) { + + /** + * Map an [AlarmCause]'s [AlarmType] to the wire alarm-type byte (ALERT=162, NOTICE=163) used to build + * the `AlarmClearCommand`. + */ + fun commandAlarmType(alarmCause: AlarmCause): Int = when (alarmCause.alarmType) { + AlarmType.ALERT -> ALARM_TYPE_ALERT + AlarmType.NOTICE -> ALARM_TYPE_NOTICE + else -> throw IllegalArgumentException("alarmType is not supported") + } + + /** + * Persist the alarm acknowledgement (remove from the alarm store) after a successful + * alarm-clear write. Returns false if the write throws. + */ + fun persistAlarmCleared(alarmId: String): Boolean = runCatching { + alarmRepository.removeAlarm(alarmId).blockingAwait() + }.isSuccess + + companion object { + + private const val ALARM_TYPE_ALERT = 162 + private const val ALARM_TYPE_NOTICE = 163 + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/CarelevoAlarmInfoUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/CarelevoAlarmInfoUseCase.kt new file mode 100644 index 000000000000..8a86519acbed --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/CarelevoAlarmInfoUseCase.kt @@ -0,0 +1,31 @@ +package app.aaps.pump.carelevo.domain.usecase.alarm + +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.repository.CarelevoAlarmInfoRepository +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import java.util.Optional +import javax.inject.Inject + +/** Domain seam over [CarelevoAlarmInfoRepository]: the persisted set of ACTIVE patch alarms. */ +class CarelevoAlarmInfoUseCase @Inject constructor( + private val repository: CarelevoAlarmInfoRepository +) { + + fun observeAlarms(): Observable>> = + repository.observeAlarms() + + /** One-shot read of all stored (= active) alarms. */ + fun getAlarmsOnce(): Single>> = repository.getAlarmsOnce() + + fun upsertAlarm(alarm: CarelevoAlarmInfo): Completable = + repository.upsertAlarm(alarm) + + /** Acknowledge = remove from the store; there is no acknowledged-alarm history. */ + fun acknowledgeAlarm(alarmId: String): Completable = + repository.removeAlarm(alarmId) + + fun clearAlarms(): Completable = + repository.clearAlarms() +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/model/CarelevoAlarmUseCaseRequestModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/model/CarelevoAlarmUseCaseRequestModel.kt new file mode 100644 index 000000000000..3b5d829c17fd --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/model/CarelevoAlarmUseCaseRequestModel.kt @@ -0,0 +1,14 @@ +package app.aaps.pump.carelevo.domain.usecase.alarm.model + +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseRequest + +data class AlarmClearUseCaseRequest( + val alarmId: String, + val alarmType: AlarmType, + val alarmCause: AlarmCause, + val address: String? = null, + val resumeType: Int? = null, + val resumeMode: Int? = null, +) : CarelevoUseCaseRequest \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/CarelevoCancelTempBasalInfusionUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/CarelevoCancelTempBasalInfusionUseCase.kt new file mode 100644 index 000000000000..5318a6fb9618 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/CarelevoCancelTempBasalInfusionUseCase.kt @@ -0,0 +1,32 @@ +package app.aaps.pump.carelevo.domain.usecase.basal + +import app.aaps.pump.carelevo.domain.model.infusion.derivePatchMode +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import org.joda.time.DateTime +import javax.inject.Inject + +class CarelevoCancelTempBasalInfusionUseCase @Inject constructor( + private val patchInfoRepository: CarelevoPatchInfoRepository, + private val infusionInfoRepository: CarelevoInfusionInfoRepository +) { + + /** + * Persist a canceled temp basal (delete the temp-basal infusion-info + recompute the patch mode from the + * remaining infusion state) after a successful cancel-temp-basal write. Returns false with no + * infusion/patch record or if any write fails. + */ + fun persistTempBasalCancelled(): Boolean = runCatching { + require(infusionInfoRepository.deleteTempBasalInfusionInfo()) { "delete temp basal infusion info is failed" } + + val infusionInfo = infusionInfoRepository.getInfusionInfoBySync() + ?: throw NullPointerException("infusion info must be not null") + + val mode = infusionInfo.derivePatchMode() + ?: throw NullPointerException("infusion info must be not null") + + val patchInfo = patchInfoRepository.getPatchInfoBySync() + ?: throw NullPointerException("patch info must be not null") + require(patchInfoRepository.updatePatchInfo(patchInfo.copy(updatedAt = DateTime.now(), mode = mode))) { "update patch info is failed" } + }.isSuccess +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/CarelevoSetBasalProgramUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/CarelevoSetBasalProgramUseCase.kt new file mode 100644 index 000000000000..6123cc44cc24 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/CarelevoSetBasalProgramUseCase.kt @@ -0,0 +1,76 @@ +package app.aaps.pump.carelevo.domain.usecase.basal + +import app.aaps.core.interfaces.profile.Profile +import app.aaps.pump.carelevo.domain.ext.generateUUID +import app.aaps.pump.carelevo.domain.ext.splitSegment +import app.aaps.pump.carelevo.domain.model.basal.CarelevoBasalSegmentDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoBasalSegmentInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import org.joda.time.DateTime +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class CarelevoSetBasalProgramUseCase @Inject constructor( + private val patchInfoRepository: CarelevoPatchInfoRepository, + private val infusionInfoRepository: CarelevoInfusionInfoRepository +) { + + /** + * Compute the basal program from [profile] (`getBasalValues` → + * `splitSegment` → 24 hourly segments → three `chunked(8)` speed lists, seqNo 0/1/2). + * [BasalProgramPlan.programs] are the wire payloads (v2 sends speed only, not hour/min), + * [BasalProgramPlan.segments] is the full 24-segment list the infusion-info persist needs. + */ + fun buildBasalProgramPlan(profile: Profile): BasalProgramPlan { + val profileBasalSegment = profile.getBasalValues() + val basalSegment = profileBasalSegment.mapIndexed { index, value -> + val nextIndex = if (profileBasalSegment.size == index + 1) 0 else index + 1 + val startTimeMinutes = TimeUnit.SECONDS.toMinutes(value.timeAsSeconds.toLong()) + val endTimeMinutes = if (nextIndex == 0) MINUTES_PER_DAY else TimeUnit.SECONDS.toMinutes(profileBasalSegment[nextIndex].timeAsSeconds.toLong()) + CarelevoBasalSegmentDomainModel( + startTime = startTimeMinutes.toInt(), + endTime = endTimeMinutes.toInt(), + speed = value.value + ) + }.splitSegment() + val programs = basalSegment.chunked(SEGMENTS_PER_PROGRAM).map { group -> group.map { it.speed } } + return BasalProgramPlan(programs = programs, segments = basalSegment) + } + + /** + * Persist the basal program (`mode=1` + basal infusion-info) after a successful set-basal-program write. + * Returns false with no patch record or if any write fails. + */ + fun persistBasalProgram(segments: List): Boolean = runCatching { + val patchInfo = patchInfoRepository.getPatchInfoBySync() + ?: throw NullPointerException("patch info must be not null") + require(patchInfoRepository.updatePatchInfo(patchInfo.copy(updatedAt = DateTime.now(), mode = 1))) { "update patch info is failed" } + require( + infusionInfoRepository.updateBasalInfusionInfo( + CarelevoBasalInfusionInfoDomainModel( + infusionId = generateUUID(), + address = patchInfo.address, + mode = 1, + segments = segments.map { + CarelevoBasalSegmentInfusionInfoDomainModel(startTime = it.startTime, endTime = it.endTime, speed = it.speed) + }, + isStop = false + ) + ) + ) { "update infusion info is failed" } + }.isSuccess + + /** The three sequential basal-program payloads ([programs], seqNo 0/1/2) plus the full [segments] list. */ + data class BasalProgramPlan( + val programs: List>, + val segments: List + ) + + companion object { + + private const val MINUTES_PER_DAY = 1440L + private const val SEGMENTS_PER_PROGRAM = 8 + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/CarelevoStartTempBasalInfusionUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/CarelevoStartTempBasalInfusionUseCase.kt new file mode 100644 index 000000000000..e86c2028d330 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/CarelevoStartTempBasalInfusionUseCase.kt @@ -0,0 +1,37 @@ +package app.aaps.pump.carelevo.domain.usecase.basal + +import app.aaps.pump.carelevo.domain.ext.generateUUID +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoTempBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import app.aaps.pump.carelevo.domain.usecase.basal.model.StartTempBasalInfusionRequestModel +import org.joda.time.DateTime +import javax.inject.Inject + +class CarelevoStartTempBasalInfusionUseCase @Inject constructor( + private val patchInfoRepository: CarelevoPatchInfoRepository, + private val infusionInfoRepository: CarelevoInfusionInfoRepository +) { + + /** + * Persist a started temp basal (`mode=2` + temp-basal infusion-info) after a successful start-temp-basal + * write. Returns false with no patch record or if any write fails. + */ + fun persistTempBasalStarted(request: StartTempBasalInfusionRequestModel): Boolean = runCatching { + val patchInfo = patchInfoRepository.getPatchInfoBySync() + ?: throw NullPointerException("patch info must be not null") + require( + infusionInfoRepository.updateTempBasalInfusionInfo( + CarelevoTempBasalInfusionInfoDomainModel( + infusionId = generateUUID(), + address = patchInfo.address, + mode = 2, + percent = request.percent, + speed = request.speed, + infusionDurationMin = request.minutes + ) + ) + ) { "update infusion info is failed" } + require(patchInfoRepository.updatePatchInfo(patchInfo.copy(updatedAt = DateTime.now(), mode = 2))) { "update patch info is failed" } + }.isSuccess +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/model/CarelevoBasalUseCaseRequestModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/model/CarelevoBasalUseCaseRequestModel.kt new file mode 100644 index 000000000000..9ed4bb76a95e --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/model/CarelevoBasalUseCaseRequestModel.kt @@ -0,0 +1,15 @@ +package app.aaps.pump.carelevo.domain.usecase.basal.model + +import app.aaps.core.interfaces.profile.Profile +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseRequest + +data class SetBasalProgramRequestModel( + val profile: Profile +) : CarelevoUseCaseRequest + +data class StartTempBasalInfusionRequestModel( + val isUnit: Boolean, + val speed: Double? = null, + val percent: Int? = null, + val minutes: Int +) : CarelevoUseCaseRequest \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoCancelExtendBolusInfusionUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoCancelExtendBolusInfusionUseCase.kt new file mode 100644 index 000000000000..885f368dda47 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoCancelExtendBolusInfusionUseCase.kt @@ -0,0 +1,33 @@ +package app.aaps.pump.carelevo.domain.usecase.bolus + +import app.aaps.pump.carelevo.domain.model.infusion.derivePatchMode +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import org.joda.time.DateTime +import javax.inject.Inject + +class CarelevoCancelExtendBolusInfusionUseCase @Inject constructor( + private val patchInfoRepository: CarelevoPatchInfoRepository, + private val infusionInfoRepository: CarelevoInfusionInfoRepository +) { + + /** + * Persist a canceled extended bolus (delete the extend-bolus infusion-info + recompute the patch mode + * from the remaining infusion state) after a successful cancel-extend-bolus write. Returns false with no + * infusion/patch record or if any write fails. (The pump-reported `infusedAmount` is surfaced by the + * caller, not persisted here — it is not reconciled into the DB.) + */ + fun persistExtendBolusCancelled(): Boolean = runCatching { + require(infusionInfoRepository.deleteExtendBolusInfusionInfo()) { "delete extend bolus infusion info is failed" } + + val infusionInfo = infusionInfoRepository.getInfusionInfoBySync() + ?: throw NullPointerException("infusion info must be not null") + + val mode = infusionInfo.derivePatchMode() + ?: throw NullPointerException("infusion info must be not null") + + val patchInfo = patchInfoRepository.getPatchInfoBySync() + ?: throw NullPointerException("patch info must be not null") + require(patchInfoRepository.updatePatchInfo(patchInfo.copy(updatedAt = DateTime.now(), mode = mode))) { "update patch info is failed" } + }.isSuccess +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoCancelImmeBolusInfusionUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoCancelImmeBolusInfusionUseCase.kt new file mode 100644 index 000000000000..71c1178d2240 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoCancelImmeBolusInfusionUseCase.kt @@ -0,0 +1,33 @@ +package app.aaps.pump.carelevo.domain.usecase.bolus + +import app.aaps.pump.carelevo.domain.model.infusion.derivePatchMode +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import org.joda.time.DateTime +import javax.inject.Inject + +class CarelevoCancelImmeBolusInfusionUseCase @Inject constructor( + private val patchInfoRepository: CarelevoPatchInfoRepository, + private val infusionInfoRepository: CarelevoInfusionInfoRepository +) { + + /** + * Persist a cancelled immediate bolus (delete the imme-bolus infusion-info + recompute the patch mode from + * the remaining infusion state) after a successful cancel-imme-bolus write. The pump-reported + * `infusedAmount` is recorded to the AAPS treatment DB by the caller (not here). Returns false with no + * infusion/patch record or if any write fails. + */ + fun persistImmeBolusCancelled(): Boolean = runCatching { + require(infusionInfoRepository.deleteImmeBolusInfusionInfo()) { "delete imme bolus infusion info is failed" } + + val infusionInfo = infusionInfoRepository.getInfusionInfoBySync() + ?: throw NullPointerException("infusion info must be not null") + + val mode = infusionInfo.derivePatchMode() + ?: throw NullPointerException("infusion info must be not null") + + val patchInfo = patchInfoRepository.getPatchInfoBySync() + ?: throw NullPointerException("patch info must be not null") + require(patchInfoRepository.updatePatchInfo(patchInfo.copy(updatedAt = DateTime.now(), mode = mode))) { "update patch info is failed" } + }.isSuccess +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoFinishImmeBolusInfusionUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoFinishImmeBolusInfusionUseCase.kt new file mode 100644 index 000000000000..83ca754e4e49 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoFinishImmeBolusInfusionUseCase.kt @@ -0,0 +1,54 @@ +package app.aaps.pump.carelevo.domain.usecase.bolus + +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.infusion.derivePatchMode +import app.aaps.pump.carelevo.domain.model.result.ResultSuccess +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.joda.time.DateTime +import javax.inject.Inject + +class CarelevoFinishImmeBolusInfusionUseCase @Inject constructor( + private val patchInfoRepository: CarelevoPatchInfoRepository, + private val infusionInfoRepository: CarelevoInfusionInfoRepository +) { + + fun execute(): Single> { + return Single.fromCallable { + runCatching { + val deleteInfusionInfoResult = infusionInfoRepository.deleteImmeBolusInfusionInfo() + if (!deleteInfusionInfoResult) { + throw IllegalStateException("delete imme bolus infusion info is failed") + } + + val infusionInfo = infusionInfoRepository.getInfusionInfoBySync() + ?: throw NullPointerException("infusion info must be not null") + val patchInfo = patchInfoRepository.getPatchInfoBySync() + ?: throw NullPointerException("patch info must be not null") + + val mode = infusionInfo.derivePatchMode() + ?: throw NullPointerException("infusion info must be not null") + + val updatePatchInfoResult = patchInfoRepository.updatePatchInfo( + patchInfo.copy(updatedAt = DateTime.now(), mode = mode) + ) + if (!updatePatchInfoResult) { + throw IllegalStateException("update patch info is failed") + } + ResultSuccess + }.fold( + onSuccess = { + ResponseResult.Success(it as CarelevoUseCaseResponse) + }, + onFailure = { + ResponseResult.Error(it) + } + ) + // subscribeOn, NOT observeOn: for Single.fromCallable the callable (blocking prefs/Gson + // I/O) runs on the SUBSCRIBING thread; observeOn only moves downstream operators. + }.subscribeOn(Schedulers.io()) + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoStartExtendBolusInfusionUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoStartExtendBolusInfusionUseCase.kt new file mode 100644 index 000000000000..e16a737dfcd3 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoStartExtendBolusInfusionUseCase.kt @@ -0,0 +1,37 @@ +package app.aaps.pump.carelevo.domain.usecase.bolus + +import app.aaps.pump.carelevo.domain.ext.generateUUID +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoExtendBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import org.joda.time.DateTime +import javax.inject.Inject + +class CarelevoStartExtendBolusInfusionUseCase @Inject constructor( + private val patchInfoRepository: CarelevoPatchInfoRepository, + private val infusionInfoRepository: CarelevoInfusionInfoRepository +) { + + /** + * Persist a started extended bolus (`mode=5` + extend-bolus infusion-info) after a successful + * start-extend-bolus write. [volume] is the total insulin, [speed] the computed U/h. Returns false with no + * patch record or if any write fails. + */ + fun persistExtendBolusStarted(volume: Double, speed: Double, minutes: Int): Boolean = runCatching { + val patchInfo = patchInfoRepository.getPatchInfoBySync() + ?: throw NullPointerException("patch info must be not null") + require( + infusionInfoRepository.updateExtendBolusInfusionInfo( + CarelevoExtendBolusInfusionInfoDomainModel( + infusionId = generateUUID(), + address = patchInfo.address, + mode = 5, + volume = volume, + speed = speed, + infusionDurationMin = minutes + ) + ) + ) { "update infusion info is failed" } + require(patchInfoRepository.updatePatchInfo(patchInfo.copy(updatedAt = DateTime.now(), mode = 5))) { "update patch info is failed" } + }.isSuccess +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoStartImmeBolusInfusionUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoStartImmeBolusInfusionUseCase.kt new file mode 100644 index 000000000000..23114633ad69 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoStartImmeBolusInfusionUseCase.kt @@ -0,0 +1,36 @@ +package app.aaps.pump.carelevo.domain.usecase.bolus + +import app.aaps.pump.carelevo.domain.ext.generateUUID +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoImmeBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import org.joda.time.DateTime +import javax.inject.Inject + +class CarelevoStartImmeBolusInfusionUseCase @Inject constructor( + private val patchInfoRepository: CarelevoPatchInfoRepository, + private val infusionInfoRepository: CarelevoInfusionInfoRepository +) { + + /** + * Persist a started immediate bolus (`mode=3` + imme-bolus infusion-info + `bolusActionSeq`) after a + * successful start-imme-bolus write. [expectedTimeSeconds] is the pump-reported expected completion + * (seeds the synthetic progress loop). Returns false with no patch record or if any write fails. + */ + fun persistImmeBolusStarted(actionSeq: Int, volume: Double, expectedTimeSeconds: Int): Boolean = runCatching { + val patchInfo = patchInfoRepository.getPatchInfoBySync() + ?: throw NullPointerException("patch info must be not null") + require( + infusionInfoRepository.updateImmeBolusInfusionInfo( + CarelevoImmeBolusInfusionInfoDomainModel( + infusionId = generateUUID(), + address = patchInfo.address, + mode = 3, + volume = volume, + infusionDurationSeconds = expectedTimeSeconds + ) + ) + ) { "update infusion info is failed" } + require(patchInfoRepository.updatePatchInfo(patchInfo.copy(updatedAt = DateTime.now(), mode = 3, bolusActionSeq = actionSeq))) { "update patch info is failed" } + }.isSuccess +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/model/CarelevoBolusUseCaseRequest.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/model/CarelevoBolusUseCaseRequest.kt new file mode 100644 index 000000000000..8a5d7179664d --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/model/CarelevoBolusUseCaseRequest.kt @@ -0,0 +1,13 @@ +package app.aaps.pump.carelevo.domain.usecase.bolus.model + +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseRequest + +data class StartImmeBolusInfusionRequestModel( + val actionSeq: Int, + val volume: Double +) : CarelevoUseCaseRequest + +data class StartExtendBolusInfusionRequestModel( + val volume: Double, + val minutes: Int +) : CarelevoUseCaseRequest \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/model/CarelevoBolusUseCaseResponse.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/model/CarelevoBolusUseCaseResponse.kt new file mode 100644 index 000000000000..c1b2b8aca35a --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/model/CarelevoBolusUseCaseResponse.kt @@ -0,0 +1,11 @@ +package app.aaps.pump.carelevo.domain.usecase.bolus.model + +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse + +data class CancelBolusInfusionResponseModel( + val infusedAmount: Double, +) : CarelevoUseCaseResponse + +data class StartImmeBolusInfusionResponseModel( + val expectSec: Int +) : CarelevoUseCaseResponse \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoDeleteInfusionInfoUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoDeleteInfusionInfoUseCase.kt new file mode 100644 index 000000000000..fce0396f1c83 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoDeleteInfusionInfoUseCase.kt @@ -0,0 +1,67 @@ +package app.aaps.pump.carelevo.domain.usecase.infusion + +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoPatchMode +import app.aaps.pump.carelevo.domain.model.infusion.derivePatchMode +import app.aaps.pump.carelevo.domain.model.result.ResultSuccess +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseRequest +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import app.aaps.pump.carelevo.domain.usecase.infusion.model.CarelevoDeleteInfusionRequestModel +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.joda.time.DateTime +import javax.inject.Inject + +class CarelevoDeleteInfusionInfoUseCase @Inject constructor( + private val patchInfoRepository: CarelevoPatchInfoRepository, + private val infusionInfoRepository: CarelevoInfusionInfoRepository +) { + + fun execute(request: CarelevoUseCaseRequest): Single> { + return Single.fromCallable { + runCatching { + require(request is CarelevoDeleteInfusionRequestModel) { + "Request must be CarelevoDeleteInfusionRequestModel" + } + val req = request + + if (req.isDeleteTempBasal) { + val ok = infusionInfoRepository.deleteTempBasalInfusionInfo() + if (!ok) error("Failed to delete temp basal infusion info") + } + if (req.isDeleteImmeBolus) { + val ok = infusionInfoRepository.deleteImmeBolusInfusionInfo() + if (!ok) error("Failed to delete immediate bolus infusion info") + } + if (req.isDeleteExtendBolus) { + val ok = infusionInfoRepository.deleteExtendBolusInfusionInfo() + if (!ok) error("Failed to delete extended bolus infusion info") + } + + val infusionInfo = infusionInfoRepository.getInfusionInfoBySync() + ?: error("Infusion info must not be null after deletion step") + + // Delete/discard path: nothing left running means the patch is stopped, so fall + // back to BASAL_STOPPED (the mid-therapy persists treat the same case as an error). + val mode = infusionInfo.derivePatchMode() ?: CarelevoPatchMode.BASAL_STOPPED + + val now = DateTime.now() + val patchInfo = patchInfoRepository.getPatchInfoBySync() + ?: error("Patch info must not be null") + + val updated = patchInfoRepository.updatePatchInfo( + patchInfo.copy(updatedAt = now, mode = mode) + ) + if (!updated) error("Failed to update patch info (mode=$mode)") + + ResultSuccess + }.fold( + onSuccess = { ResponseResult.Success(it as CarelevoUseCaseResponse) }, + onFailure = { ResponseResult.Error(it) } + ) + // subscribeOn, NOT observeOn — see CarelevoFinishImmeBolusInfusionUseCase. + }.subscribeOn(Schedulers.io()) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoInfusionInfoMonitorUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoInfusionInfoMonitorUseCase.kt new file mode 100644 index 000000000000..967c2f6719ce --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoInfusionInfoMonitorUseCase.kt @@ -0,0 +1,32 @@ +package app.aaps.pump.carelevo.domain.usecase.infusion + +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.schedulers.Schedulers +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +class CarelevoInfusionInfoMonitorUseCase @Inject constructor( + private val infusionInfoRepository: CarelevoInfusionInfoRepository +) { + + fun execute(): Observable> { + return runCatching { + infusionInfoRepository.getInfusionInfo() + }.fold( + onSuccess = { resultObservable -> + resultObservable + .observeOn(Schedulers.io()) + .map { result -> + ResponseResult.Success(result.getOrNull()) + } + }, + onFailure = { error -> + error.printStackTrace() + Observable.just(ResponseResult.Error(error)) + } + ) + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoPumpResumeUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoPumpResumeUseCase.kt new file mode 100644 index 000000000000..33b1a07eb97f --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoPumpResumeUseCase.kt @@ -0,0 +1,26 @@ +package app.aaps.pump.carelevo.domain.usecase.infusion + +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import org.joda.time.DateTime +import javax.inject.Inject + +class CarelevoPumpResumeUseCase @Inject constructor( + private val patchInfoRepository: CarelevoPatchInfoRepository, + private val infusionInfoRepository: CarelevoInfusionInfoRepository +) { + + /** + * Persist the resumed state (basalInfusionInfo `mode=1,isStop=false` + patchInfo `isStopped=false, …`) + * after a successful pump-resume write on the BLE stack. Returns `false` on any missing record or failed + * write. + */ + fun persistResumed(): Boolean { + val basalInfusionInfo = infusionInfoRepository.getInfusionInfoBySync()?.basalInfusionInfo ?: return false + if (!infusionInfoRepository.updateBasalInfusionInfo(basalInfusionInfo.copy(updatedAt = DateTime.now(), mode = 1, isStop = false))) return false + val patchInfo = patchInfoRepository.getPatchInfoBySync() ?: return false + return patchInfoRepository.updatePatchInfo( + patchInfo.copy(isStopped = false, stopMinutes = null, stopMode = null, isForceStopped = null, mode = 1) + ) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoPumpStopUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoPumpStopUseCase.kt new file mode 100644 index 000000000000..f7a9ae2c101d --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoPumpStopUseCase.kt @@ -0,0 +1,26 @@ +package app.aaps.pump.carelevo.domain.usecase.infusion + +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import org.joda.time.DateTime +import javax.inject.Inject + +class CarelevoPumpStopUseCase @Inject constructor( + private val patchInfoRepository: CarelevoPatchInfoRepository, + private val infusionInfoRepository: CarelevoInfusionInfoRepository +) { + + /** + * Persist the suspended state (basalInfusionInfo `mode=0,isStop=true` + patchInfo `isStopped=true, …`) + * after a successful pump-stop write on the BLE stack. Returns `false` on any missing record or failed + * write. + */ + fun persistStopped(durationMin: Int): Boolean { + val basalInfusionInfo = infusionInfoRepository.getInfusionInfoBySync()?.basalInfusionInfo ?: return false + if (!infusionInfoRepository.updateBasalInfusionInfo(basalInfusionInfo.copy(updatedAt = DateTime.now(), mode = 0, isStop = true))) return false + val patchInfo = patchInfoRepository.getPatchInfoBySync() ?: return false + return patchInfoRepository.updatePatchInfo( + patchInfo.copy(updatedAt = DateTime.now(), isStopped = true, stopMinutes = durationMin, stopMode = 0, isForceStopped = false, mode = 0) + ) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/model/CarelevoDeleteInfusionRequestModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/model/CarelevoDeleteInfusionRequestModel.kt new file mode 100644 index 000000000000..f011e0694534 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/model/CarelevoDeleteInfusionRequestModel.kt @@ -0,0 +1,9 @@ +package app.aaps.pump.carelevo.domain.usecase.infusion.model + +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseRequest + +data class CarelevoDeleteInfusionRequestModel( + val isDeleteTempBasal: Boolean, + val isDeleteImmeBolus: Boolean, + val isDeleteExtendBolus: Boolean +) : CarelevoUseCaseRequest diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/model/CarelevoInfusionUseCaseRequestModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/model/CarelevoInfusionUseCaseRequestModel.kt new file mode 100644 index 000000000000..4bf98f980105 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/model/CarelevoInfusionUseCaseRequestModel.kt @@ -0,0 +1,7 @@ +package app.aaps.pump.carelevo.domain.usecase.infusion.model + +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseRequest + +data class CarelevoPumpStopRequestModel( + val durationMin: Int +) : CarelevoUseCaseRequest \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoConnectNewPatchUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoConnectNewPatchUseCase.kt new file mode 100644 index 000000000000..8c1ca52ff3be --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoConnectNewPatchUseCase.kt @@ -0,0 +1,63 @@ +package app.aaps.pump.carelevo.domain.usecase.patch + +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import app.aaps.pump.carelevo.domain.usecase.patch.model.CarelevoConnectNewPatchRequestModel +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import javax.inject.Inject + +class CarelevoConnectNewPatchUseCase @Inject constructor( + private val patchInfoRepository: CarelevoPatchInfoRepository, +) { + + companion object { + + private val BOOT_DATE_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyMMddHHmm") + } + + /** + * Persist the freshly paired patch — used by the pairing session (`CarelevoBleSession.runPairing`). + * bootDateTime is fabricated from the phone clock — it is never on the wire; the persistence layer + * stamps it as `yyMMddHHmm`. Returns false if the DB write fails. + */ + fun persistNewPatch( + address: String, + serialNumber: String, + firmwareVersion: String, + modelName: String, + request: CarelevoConnectNewPatchRequestModel + ): Boolean { + val bootDateTime = LocalDateTime.now().format(BOOT_DATE_TIME_FORMATTER) + return patchInfoRepository.updatePatchInfo( + CarelevoPatchInfoDomainModel( + address = address, + manufactureNumber = serialNumber, + firmwareVersion = firmwareVersion, + bootDateTime = bootDateTime, + bootDateTimeUtcMillis = parseBootDateTimeUtcMillis(bootDateTime), + modelName = modelName, + insulinAmount = request.volume, + insulinRemain = request.volume.toDouble(), + thresholdInsulinRemain = request.remains, + thresholdExpiry = request.expiry, + thresholdMaxBasalSpeed = request.maxBasalSpeed, + thresholdMaxBolusDose = request.maxVolume + ) + ) + } + + private fun parseBootDateTimeUtcMillis(raw: String?): Long? { + if (raw.isNullOrBlank()) { + return null + } + + return runCatching { + LocalDateTime.parse(raw, BOOT_DATE_TIME_FORMATTER) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + }.getOrNull() + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchForceDiscardUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchForceDiscardUseCase.kt new file mode 100644 index 000000000000..b4f2321a665b --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchForceDiscardUseCase.kt @@ -0,0 +1,53 @@ +package app.aaps.pump.carelevo.domain.usecase.patch + +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.result.ResultSuccess +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoUserSettingInfoRepository +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.joda.time.DateTime +import javax.inject.Inject + +class CarelevoPatchForceDiscardUseCase @Inject constructor( + private val patchInfoRepository: CarelevoPatchInfoRepository, + private val infusionInfoRepository: CarelevoInfusionInfoRepository, + private val userSettingInfoRepository: CarelevoUserSettingInfoRepository +) { + + fun execute(): Single> { + return Single.fromCallable { + runCatching { + val userSettingInfo = userSettingInfoRepository.getUserSettingInfoBySync() + ?: throw NullPointerException("user setting info must be not null") + + val updateUserSettingInfoResult = userSettingInfoRepository.updateUserSettingInfo( + userSettingInfo.copy(updatedAt = DateTime.now(), needMaxBolusDoseSyncPatch = false, needMaxBasalSpeedSyncPatch = false, needLowInsulinNoticeAmountSyncPatch = false) + ) + if (!updateUserSettingInfoResult) { + throw IllegalStateException("update user setting info is failed") + } + + val deleteInfusionInfoResult = infusionInfoRepository.deleteInfusionInfo() + if (!deleteInfusionInfoResult) { + throw IllegalStateException("delete infusion info is failed") + } + + val deletePatchInfoResult = patchInfoRepository.deletePatchInfo() + if (!deletePatchInfoResult) { + throw IllegalStateException("delete patch info is failed") + } + ResultSuccess + }.fold( + onSuccess = { + ResponseResult.Success(it as CarelevoUseCaseResponse) + }, + onFailure = { + ResponseResult.Error(it) + } + ) + }.subscribeOn(Schedulers.io()) + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchInfoMonitorUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchInfoMonitorUseCase.kt new file mode 100644 index 000000000000..f33306eee1a9 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchInfoMonitorUseCase.kt @@ -0,0 +1,29 @@ +package app.aaps.pump.carelevo.domain.usecase.patch + +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import io.reactivex.rxjava3.core.Observable +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +class CarelevoPatchInfoMonitorUseCase @Inject constructor( + private val patchInfoRepository: CarelevoPatchInfoRepository +) { + + fun execute(): Observable> { + return runCatching { + patchInfoRepository.getPatchInfo() + }.fold( + onSuccess = { resultObservable -> + resultObservable + .map { result -> + ResponseResult.Success(result.getOrNull()) + } + }, + onFailure = { error -> + Observable.just(ResponseResult.Error(error)) + } + ) + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchNeedleInsertionCheckUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchNeedleInsertionCheckUseCase.kt new file mode 100644 index 000000000000..78162e1dfbf4 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchNeedleInsertionCheckUseCase.kt @@ -0,0 +1,24 @@ +package app.aaps.pump.carelevo.domain.usecase.patch + +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import org.joda.time.DateTime +import javax.inject.Inject + +class CarelevoPatchNeedleInsertionCheckUseCase @Inject constructor( + private val patchInfoRepository: CarelevoPatchInfoRepository +) { + + /** + * Persist the cannula-insertion outcome: `checkNeedle=true` on [success], else `checkNeedle=false` + + * `needleFailedCount++`. Returns false with no patch record or on a failed write. + */ + fun persistNeedleResult(success: Boolean): Boolean { + val patchInfo = patchInfoRepository.getPatchInfoBySync() ?: return false + return if (success) { + patchInfoRepository.updatePatchInfo(patchInfo.copy(updatedAt = DateTime.now(), checkNeedle = true)) + } else { + val nextFailedCount = (patchInfo.needleFailedCount ?: 0) + 1 + patchInfoRepository.updatePatchInfo(patchInfo.copy(updatedAt = DateTime.now(), checkNeedle = false, needleFailedCount = nextFailedCount)) + } + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchRptInfusionInfoProcessUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchRptInfusionInfoProcessUseCase.kt new file mode 100644 index 000000000000..bbbb09d3d2ed --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchRptInfusionInfoProcessUseCase.kt @@ -0,0 +1,63 @@ +package app.aaps.pump.carelevo.domain.usecase.patch + +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.result.ResultSuccess +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseRequest +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import app.aaps.pump.carelevo.domain.usecase.patch.model.CarelevoPatchRptInfusionInfoDefaultRequestModel +import app.aaps.pump.carelevo.domain.usecase.patch.model.CarelevoPatchRptInfusionInfoRequestModel +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import org.joda.time.DateTime +import javax.inject.Inject + +class CarelevoPatchRptInfusionInfoProcessUseCase @Inject constructor( + private val patchInfoRepository: CarelevoPatchInfoRepository +) { + + fun execute(request: CarelevoUseCaseRequest): Single> { + return Single.fromCallable { + runCatching { + if (request !is CarelevoPatchRptInfusionInfoRequestModel && request !is CarelevoPatchRptInfusionInfoDefaultRequestModel) { + throw IllegalArgumentException("request is not carelevoPatchRptInfusionInfoRequestModel") + } + + val patchInfo = patchInfoRepository.getPatchInfoBySync() + ?: throw NullPointerException("patch info must be not null") + + val updatePatchInfoResult = patchInfoRepository.updatePatchInfo( + if (request is CarelevoPatchRptInfusionInfoRequestModel) { + patchInfo.copy( + mode = request.mode, + runningMinutes = request.runningMinute, + insulinRemain = request.remains, + infusedTotalBasalAmount = request.infusedTotalBasalAmount, + infusedTotalBolusAmount = request.infusedTotalBolusAmount, + pumpState = request.pumpState, + updatedAt = DateTime.now() + ) + } else if (request is CarelevoPatchRptInfusionInfoDefaultRequestModel) { + patchInfo.copy( + insulinRemain = request.remains, + ) + } else { + patchInfo + } + ) + + if (!updatePatchInfoResult) { + throw IllegalStateException("update patch info is failed") + } + ResultSuccess + }.fold( + onSuccess = { + ResponseResult.Success(it as CarelevoUseCaseResponse) + }, + onFailure = { + ResponseResult.Error(it) + } + ) + }.subscribeOn(Schedulers.io()) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchSafetyCheckUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchSafetyCheckUseCase.kt new file mode 100644 index 000000000000..5c23172a2623 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchSafetyCheckUseCase.kt @@ -0,0 +1,19 @@ +package app.aaps.pump.carelevo.domain.usecase.patch + +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import org.joda.time.DateTime +import javax.inject.Inject + +class CarelevoPatchSafetyCheckUseCase @Inject constructor( + private val patchInfoRepository: CarelevoPatchInfoRepository +) { + + /** + * Persist the safety-check pass (`checkSafety=true`) after the BLE safety-check succeeds. Returns false + * with no patch record or on a failed write. + */ + fun persistSafetyChecked(): Boolean { + val patchInfo = patchInfoRepository.getPatchInfoBySync() ?: return false + return patchInfoRepository.updatePatchInfo(patchInfo.copy(checkSafety = true, updatedAt = DateTime.now())) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/model/CarelevoConnectNewPatchRequestModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/model/CarelevoConnectNewPatchRequestModel.kt new file mode 100644 index 000000000000..392b524daae6 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/model/CarelevoConnectNewPatchRequestModel.kt @@ -0,0 +1,12 @@ +package app.aaps.pump.carelevo.domain.usecase.patch.model + +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseRequest + +data class CarelevoConnectNewPatchRequestModel( + val volume: Int, + val expiry: Int, + val remains: Int, + val maxBasalSpeed: Double, + val maxVolume: Double, + val isBuzzOn: Boolean +) : CarelevoUseCaseRequest \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/model/CarelevoPatchRptInfusionInfoRequestModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/model/CarelevoPatchRptInfusionInfoRequestModel.kt new file mode 100644 index 000000000000..df2e358d8aa3 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/model/CarelevoPatchRptInfusionInfoRequestModel.kt @@ -0,0 +1,18 @@ +package app.aaps.pump.carelevo.domain.usecase.patch.model + +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseRequest + +data class CarelevoPatchRptInfusionInfoRequestModel( + val runningMinute: Int, + val remains: Double, + val infusedTotalBasalAmount: Double, + val infusedTotalBolusAmount: Double, + val pumpState: Int, + val mode: Int, + val currentInfusedProgramVolume: Double, + val realInfusedTime: Int +) : CarelevoUseCaseRequest + +data class CarelevoPatchRptInfusionInfoDefaultRequestModel( + val remains: Double, +) : CarelevoUseCaseRequest \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoCreateUserSettingInfoUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoCreateUserSettingInfoUseCase.kt new file mode 100644 index 000000000000..6c76a3657442 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoCreateUserSettingInfoUseCase.kt @@ -0,0 +1,47 @@ +package app.aaps.pump.carelevo.domain.usecase.userSetting + +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.result.ResultSuccess +import app.aaps.pump.carelevo.domain.model.userSetting.CarelevoUserSettingInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoUserSettingInfoRepository +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseRequest +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import app.aaps.pump.carelevo.domain.usecase.userSetting.model.CarelevoUserSettingInfoRequestModel +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import javax.inject.Inject + +class CarelevoCreateUserSettingInfoUseCase @Inject constructor( + private val userSettingInfoRepository: CarelevoUserSettingInfoRepository +) { + + fun execute(request: CarelevoUseCaseRequest): Single> { + return Single.fromCallable { + runCatching { + if (request !is CarelevoUserSettingInfoRequestModel) { + throw IllegalArgumentException("request is not CarelevoUserSettingInfoRequestModel") + } + + val createResult = userSettingInfoRepository.updateUserSettingInfo( + CarelevoUserSettingInfoDomainModel( + lowInsulinNoticeAmount = request.lowInsulinNoticeAmount, + maxBasalSpeed = request.maxBasalSpeed, + maxBolusDose = request.maxBolusDose + ) + ) + + if (!createResult) { + throw IllegalStateException("create user setting info is failed") + } + ResultSuccess + }.fold( + onSuccess = { + ResponseResult.Success(it as CarelevoUseCaseResponse) + }, + onFailure = { + ResponseResult.Error(it) + } + ) + }.subscribeOn(Schedulers.io()) + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoDeleteUserSettingInfoUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoDeleteUserSettingInfoUseCase.kt new file mode 100644 index 000000000000..788d1643403f --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoDeleteUserSettingInfoUseCase.kt @@ -0,0 +1,33 @@ +package app.aaps.pump.carelevo.domain.usecase.userSetting + +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.result.ResultSuccess +import app.aaps.pump.carelevo.domain.repository.CarelevoUserSettingInfoRepository +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import javax.inject.Inject + +class CarelevoDeleteUserSettingInfoUseCase @Inject constructor( + private val userSettingInfoRepository: CarelevoUserSettingInfoRepository +) { + + fun execute(): Single> { + return Single.fromCallable { + runCatching { + val deleteResult = userSettingInfoRepository.deleteUserSettingInfo() + if (!deleteResult) { + throw IllegalStateException("delete user setting info is failed") + } + ResultSuccess + }.fold( + onSuccess = { + ResponseResult.Success(it as CarelevoUseCaseResponse) + }, + onFailure = { + ResponseResult.Error(it) + } + ) + }.subscribeOn(Schedulers.io()) + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoUpdateLowInsulinNoticeAmountUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoUpdateLowInsulinNoticeAmountUseCase.kt new file mode 100644 index 000000000000..0ce45c9b5397 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoUpdateLowInsulinNoticeAmountUseCase.kt @@ -0,0 +1,22 @@ +package app.aaps.pump.carelevo.domain.usecase.userSetting + +import app.aaps.pump.carelevo.domain.repository.CarelevoUserSettingInfoRepository +import org.joda.time.DateTime +import javax.inject.Inject + +class CarelevoUpdateLowInsulinNoticeAmountUseCase @Inject constructor( + private val userSettingInfoRepository: CarelevoUserSettingInfoRepository +) { + + /** + * Persist the low-insulin-notice setting. [synced] = the patch confirmed the push; when false, + * `needLowInsulinNoticeAmountSyncPatch` stays true so the deferred-sync re-pushes on reconnect. Returns + * false with no setting record or on a failed write. + */ + fun persistLowInsulinNoticeAmount(value: Int, synced: Boolean): Boolean { + val userSettingInfo = userSettingInfoRepository.getUserSettingInfoBySync() ?: return false + return userSettingInfoRepository.updateUserSettingInfo( + userSettingInfo.copy(updatedAt = DateTime.now(), lowInsulinNoticeAmount = value, needLowInsulinNoticeAmountSyncPatch = !synced) + ) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoUpdateMaxBolusDoseUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoUpdateMaxBolusDoseUseCase.kt new file mode 100644 index 000000000000..d585571bbef2 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoUpdateMaxBolusDoseUseCase.kt @@ -0,0 +1,30 @@ +package app.aaps.pump.carelevo.domain.usecase.userSetting + +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoUserSettingInfoRepository +import org.joda.time.DateTime +import javax.inject.Inject + +class CarelevoUpdateMaxBolusDoseUseCase @Inject constructor( + private val infusionInfoRepository: CarelevoInfusionInfoRepository, + private val userSettingInfoRepository: CarelevoUserSettingInfoRepository +) { + + /** True if an immediate or extended bolus is in progress — a setting must not be pushed mid-bolus. */ + fun isBolusRunning(): Boolean { + val infusionInfo = infusionInfoRepository.getInfusionInfoBySync() + return infusionInfo?.immeBolusInfusionInfo != null || infusionInfo?.extendBolusInfusionInfo != null + } + + /** + * Persist the max-bolus setting. [synced] = the patch confirmed the push; when false, + * `needMaxBolusDoseSyncPatch` stays true so the deferred-sync re-pushes on the next reconnect. + * Returns false with no setting record or on a failed write. + */ + fun persistMaxBolusDose(value: Double, synced: Boolean): Boolean { + val userSettingInfo = userSettingInfoRepository.getUserSettingInfoBySync() ?: return false + return userSettingInfoRepository.updateUserSettingInfo( + userSettingInfo.copy(updatedAt = DateTime.now(), maxBolusDose = value, needMaxBolusDoseSyncPatch = !synced) + ) + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoUserSettingInfoMonitorUseCase.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoUserSettingInfoMonitorUseCase.kt new file mode 100644 index 000000000000..aa56d2317b68 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoUserSettingInfoMonitorUseCase.kt @@ -0,0 +1,29 @@ +package app.aaps.pump.carelevo.domain.usecase.userSetting + +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.repository.CarelevoUserSettingInfoRepository +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import io.reactivex.rxjava3.core.Observable +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +class CarelevoUserSettingInfoMonitorUseCase @Inject constructor( + private val userSettingInfoRepository: CarelevoUserSettingInfoRepository +) { + + fun execute(): Observable> { + return runCatching { + userSettingInfoRepository.getUserSettingInfo() + }.fold( + onSuccess = { resultObservable -> + resultObservable + .map { result -> + ResponseResult.Success(result.getOrNull()) + } + }, + onFailure = { + Observable.just(ResponseResult.Error(it)) + } + ) + } +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/model/CarelevoUserSettingInfoRequestModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/model/CarelevoUserSettingInfoRequestModel.kt new file mode 100644 index 000000000000..c758b154b470 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/model/CarelevoUserSettingInfoRequestModel.kt @@ -0,0 +1,11 @@ +package app.aaps.pump.carelevo.domain.usecase.userSetting.model + +import app.aaps.pump.carelevo.common.model.PatchState +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseRequest + +data class CarelevoUserSettingInfoRequestModel( + val patchState: PatchState? = null, + val lowInsulinNoticeAmount: Int? = null, + val maxBasalSpeed: Double? = null, + val maxBolusDose: Double? = null +) : CarelevoUseCaseRequest \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ext/AlarmExt.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ext/AlarmExt.kt new file mode 100644 index 000000000000..f8ee58e38c76 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ext/AlarmExt.kt @@ -0,0 +1,274 @@ +package app.aaps.pump.carelevo.ext + +import androidx.annotation.StringRes +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.domain.type.AlarmCause + +/** + * All display resources for one [AlarmCause]. Single source of truth for BOTH presentation + * surfaces — the in-app alarm screen ([screenDescRes]) and the Android notification + * ([notificationDescRes]); title and confirm-button label are shared. One table maps each cause + * to its string resources. + */ +data class AlarmStringResources( + @param:StringRes val titleRes: Int, + @param:StringRes val screenDescRes: Int?, + @param:StringRes val notificationDescRes: Int?, + @param:StringRes val btnRes: Int +) + +/** In-app alarm screen resources: (title, description?, button). */ +fun AlarmCause.transformStringResources(): Triple = + stringResources().let { Triple(it.titleRes, it.screenDescRes, it.btnRes) } + +/** Notification resources: (title, description?, button). */ +fun AlarmCause.transformNotificationStringResources(): Triple = + stringResources().let { Triple(it.titleRes, it.notificationDescRes, it.btnRes) } + +fun AlarmCause.stringResources(): AlarmStringResources = when (this) { + AlarmCause.ALARM_WARNING_LOW_INSULIN -> AlarmStringResources( + R.string.alarm_feat_title_warning_low_insulin, + R.string.alarm_feat_desc_warning_low_insulin, + R.string.alarm_notification_desc_warning_low_insulin, + R.string.alarm_feat_btn_patch_discard + ) + + AlarmCause.ALARM_WARNING_PATCH_EXPIRED_PHASE_1, + AlarmCause.ALARM_WARNING_PATCH_EXPIRED -> AlarmStringResources( + R.string.alarm_feat_title_warning_expired_patch, + R.string.alarm_feat_desc_warning_expired_patch, + R.string.alarm_notification_desc_warning_expired_patch, + R.string.alarm_feat_btn_patch_discard + ) + + AlarmCause.ALARM_WARNING_LOW_BATTERY -> AlarmStringResources( + R.string.alarm_feat_title_warning_low_battery, + R.string.alarm_feat_desc_warning_low_battery, + R.string.alarm_notification_desc_warning_low_battery, + R.string.alarm_feat_btn_patch_force_discard + ) + + AlarmCause.ALARM_WARNING_INVALID_TEMPERATURE -> AlarmStringResources( + R.string.alarm_feat_title_warning_invalid_temperature, + R.string.alarm_feat_desc_warning_invalid_temperature, + R.string.alarm_notification_desc_warning_invalid_temperature, + R.string.alarm_feat_btn_patch_discard + ) + + AlarmCause.ALARM_WARNING_NOT_USED_APP_AUTO_OFF -> AlarmStringResources( + R.string.alarm_feat_title_warning_not_used_app, + R.string.alarm_feat_desc_warning_not_used_app, + R.string.alarm_notification_desc_warning_not_used_app, + R.string.alarm_feat_btn_resume_infusion + ) + + AlarmCause.ALARM_WARNING_BLE_NOT_CONNECTED -> AlarmStringResources( + R.string.alarm_feat_title_warning_not_connected_ble, + null, + null, + R.string.alarm_feat_btn_patch_force_discard + ) + + AlarmCause.ALARM_WARNING_INCOMPLETE_PATCH_SETTING -> AlarmStringResources( + R.string.alarm_feat_title_warning_incomplete_patch_setting, + R.string.alarm_feat_desc_warning_incomplete_patch_setting, + R.string.alarm_notification_desc_warning_incomplete_patch_setting, + R.string.alarm_feat_btn_patch_discard + ) + + AlarmCause.ALARM_WARNING_SELF_DIAGNOSIS_FAILED -> AlarmStringResources( + R.string.alarm_feat_title_warning_failed_safety_check, + R.string.alarm_feat_desc_warning_failed_safety_check, + R.string.alarm_notification_desc_warning_failed_safety_check, + R.string.alarm_feat_btn_patch_discard + ) + + AlarmCause.ALARM_WARNING_PATCH_ERROR -> AlarmStringResources( + R.string.alarm_feat_title_warning_patch_error, + R.string.alarm_feat_desc_warning_patch_error, + R.string.alarm_notification_desc_warning_patch_error, + R.string.alarm_feat_btn_patch_discard + ) + + AlarmCause.ALARM_WARNING_PUMP_CLOGGED -> AlarmStringResources( + R.string.alarm_feat_title_warning_infusion_clogged, + R.string.alarm_feat_desc_warning_infusion_clogged, + R.string.alarm_notification_desc_warning_infusion_clogged, + R.string.alarm_feat_btn_patch_discard + ) + + AlarmCause.ALARM_WARNING_NEEDLE_INSERTION_ERROR -> AlarmStringResources( + R.string.alarm_feat_title_warning_needle_injection_error, + R.string.alarm_feat_desc_warning_needle_injection_error, + R.string.alarm_notification_desc_warning_needle_injection_error, + R.string.alarm_feat_btn_patch_discard + ) + + AlarmCause.ALARM_ALERT_OUT_OF_INSULIN -> AlarmStringResources( + R.string.alarm_feat_title_alert_low_insulin, + R.string.alarm_feat_desc_alert_low_insulin, + R.string.alarm_notification_desc_alert_low_insulin, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_ALERT_PATCH_EXPIRED_PHASE_1 -> AlarmStringResources( + R.string.alarm_feat_title_alert_expired_patch_phase1, + R.string.alarm_feat_desc_alert_expired_patch_phase1, + R.string.alarm_notification_desc_alert_expired_patch_phase1, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_ALERT_PATCH_EXPIRED_PHASE_2 -> AlarmStringResources( + R.string.alarm_feat_title_alert_expired_patch_phase2, + R.string.alarm_feat_desc_alert_expired_patch_phase2, + R.string.alarm_notification_desc_alert_expired_patch_phase2, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_ALERT_LOW_BATTERY -> AlarmStringResources( + R.string.alarm_feat_title_alert_low_battery, + R.string.alarm_feat_desc_alert_low_battery, + R.string.alarm_notification_desc_alert_low_battery, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_ALERT_INVALID_TEMPERATURE -> AlarmStringResources( + R.string.alarm_feat_title_alert_invalid_temperature, + R.string.alarm_feat_desc_alert_invalid_temperature, + R.string.alarm_notification_desc_alert_invalid_temperature, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_ALERT_APP_NO_USE -> AlarmStringResources( + R.string.alarm_feat_title_alert_not_used_app, + R.string.alarm_feat_desc_alert_not_used_app, + R.string.alarm_notification_desc_alert_not_used_app, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_ALERT_BLE_NOT_CONNECTED -> AlarmStringResources( + R.string.alarm_feat_title_alert_not_connected_ble, + null, + null, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_ALERT_PATCH_APPLICATION_INCOMPLETE -> AlarmStringResources( + R.string.alarm_feat_title_alert_incomplete_patch_setting, + R.string.alarm_feat_desc_alert_incomplete_patch_setting, + R.string.alarm_notification_desc_alert_incomplete_patch_setting, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_ALERT_RESUME_INSULIN_DELIVERY_TIMEOUT -> AlarmStringResources( + R.string.alarm_feat_title_alert_resume_infusion, + R.string.alarm_feat_desc_alert_resume_infusion, + R.string.alarm_notification_desc_alert_resume_infusion, + R.string.alarm_feat_btn_resume_infusion + ) + + AlarmCause.ALARM_ALERT_BLUETOOTH_OFF -> AlarmStringResources( + R.string.alarm_feat_title_alert_off_bluetooth, + R.string.alarm_feat_desc_alert_off_bluetooth, + R.string.alarm_notification_desc_alert_off_bluetooth, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_NOTICE_LOW_INSULIN -> AlarmStringResources( + R.string.alarm_feat_title_notice_low_insulin, + R.string.alarm_feat_desc_notice_low_insulin, + R.string.alarm_notification_desc_notice_low_insulin, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_NOTICE_PATCH_EXPIRED -> AlarmStringResources( + R.string.alarm_feat_title_notice_expired_patch, + R.string.alarm_feat_desc_notice_expired_patch, + R.string.alarm_notification_desc_notice_expired_patch, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_NOTICE_ATTACH_PATCH_CHECK -> AlarmStringResources( + R.string.alarm_feat_title_notice_check_patch, + null, + null, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_NOTICE_BG_CHECK -> AlarmStringResources( + R.string.alarm_feat_title_notice_check_bg, + R.string.alarm_feat_desc_notice_check_bg, + R.string.alarm_notification_desc_notice_check_bg, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_NOTICE_TIME_ZONE_CHANGED -> AlarmStringResources( + R.string.alarm_feat_title_notice_change_time_zone, + R.string.alarm_feat_desc_notice_change_time_zone, + R.string.alarm_notification_desc_notice_change_time_zone, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_NOTICE_LGS_START -> AlarmStringResources( + R.string.alarm_feat_title_notice_lgs_started, + R.string.alarm_feat_desc_notice_lgs_started, + R.string.alarm_notification_desc_notice_lgs_started, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_NOTICE_LGS_FINISHED_DISCONNECTED_PATCH_OR_CGM -> AlarmStringResources( + R.string.alarm_feat_title_notice_lgs_ended, + R.string.alarm_feat_desc_notice_lgs_ended_disconnected_patch_or_cgm, + R.string.alarm_notification_desc_notice_lgs_ended_disconnected_patch_or_cgm, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_NOTICE_LGS_FINISHED_PAUSE_LGS -> AlarmStringResources( + R.string.alarm_feat_title_notice_lgs_ended, + R.string.alarm_feat_desc_notice_lgs_ended_pause_lgs, + R.string.alarm_notification_desc_notice_lgs_ended_pause_lgs, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_NOTICE_LGS_FINISHED_TIME_OVER -> AlarmStringResources( + R.string.alarm_feat_title_notice_lgs_ended, + R.string.alarm_feat_desc_notice_lgs_ended_time_over, + R.string.alarm_notification_desc_notice_lgs_ended_time_over, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_NOTICE_LGS_FINISHED_OFF_LGS -> AlarmStringResources( + R.string.alarm_feat_title_notice_lgs_ended, + R.string.alarm_feat_desc_notice_lgs_ended_off_lgs, + R.string.alarm_notification_desc_notice_lgs_ended_off_lgs, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_NOTICE_LGS_FINISHED_HIGH_BG -> AlarmStringResources( + R.string.alarm_feat_title_notice_lgs_ended, + R.string.alarm_feat_desc_notice_lgs_ended_high_bg, + R.string.alarm_notification_desc_notice_lgs_ended_high_bg, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_NOTICE_LGS_FINISHED_UNKNOWN -> AlarmStringResources( + R.string.alarm_feat_title_notice_lgs_ended, + R.string.alarm_feat_desc_notice_lgs_ended_unknown, + R.string.alarm_notification_desc_notice_lgs_ended_unknown, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_NOTICE_LGS_NOT_WORKING -> AlarmStringResources( + R.string.alarm_feat_title_notice_lgs_error, + R.string.alarm_feat_desc_notice_lgs_error, + R.string.alarm_notification_desc_notice_lgs_error, + R.string.common_btn_ok + ) + + AlarmCause.ALARM_UNKNOWN -> AlarmStringResources( + R.string.alarm_feat_title_notice_unknown, + R.string.alarm_feat_desc_unknown, + R.string.alarm_feat_desc_unknown, + R.string.common_btn_ok + ) +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ext/CarelevoValueExt.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ext/CarelevoValueExt.kt new file mode 100644 index 000000000000..489f9647ff79 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/ext/CarelevoValueExt.kt @@ -0,0 +1,54 @@ +package app.aaps.pump.carelevo.ext + +import java.math.BigInteger +import java.util.Locale +import kotlin.experimental.xor + +internal fun ByteArray.convertBytesToHex(): String { + return StringBuilder().let { + for (byte in this) { + it.append(String.format("0x%02x", byte)) + } + it + }.toString() +} + +internal fun String.convertHexToByteArray(): ByteArray { + var returnData: List = ArrayList() + + val str = toString().replace(" ", "") + var hex = str.replace("0x", "") + + hex = hex.uppercase(Locale.getDefault()) + + val intCount = (hex.length % 2) == 0 + if (intCount) { + try { + returnData = ArrayList() + for (i in 0..hex.length step 2) { + if (i < hex.length) { + val subString = hex.substring(i until i + 2) + returnData.add(BigInteger(subString, 16).toByte()) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + return returnData.toByteArray() +} + +internal fun ByteArray.checkSum(key: Int, result: Int): Boolean { + val checkResult = this.fold(key.toByte()) { acc, checkByte -> + acc xor checkByte + } + return checkResult == result.toByte() +} + +internal fun ByteArray.checkSumV2(key: Int): Byte { + val checkResult = this.fold(key.toByte()) { acc, checkByte -> + acc xor checkByte + } + return checkResult +} \ No newline at end of file diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/model/CarelevoOverviewUiModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/model/CarelevoOverviewUiModel.kt new file mode 100644 index 000000000000..bf2629ca7e6e --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/model/CarelevoOverviewUiModel.kt @@ -0,0 +1,14 @@ +package app.aaps.pump.carelevo.presentation.model + +data class CarelevoOverviewUiModel( + val serialNumber: String, + val lotNumber: String, + val bootDateTimeUi: String, + val expirationTime: String, + val infusionStatus: Int?, + val insulinRemainText: String, + val totalBasal: Double, + val totalBolus: Double, + val isPumpStopped: Boolean, + val runningRemainMinutes: Int +) diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/model/CarelevoUiEventModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/model/CarelevoUiEventModel.kt new file mode 100644 index 000000000000..06da93a6e702 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/model/CarelevoUiEventModel.kt @@ -0,0 +1,90 @@ +package app.aaps.pump.carelevo.presentation.model + +import androidx.annotation.StringRes +import app.aaps.pump.carelevo.common.model.Event +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo + +sealed class CarelevoOverviewEvent : Event { + + data object NoAction : CarelevoOverviewEvent() + data object ShowMessageBluetoothNotEnabled : CarelevoOverviewEvent() + data object ShowMessageCarelevoIsNotConnected : CarelevoOverviewEvent() + data object DiscardFailed : CarelevoOverviewEvent() + data object ResumePumpFailed : CarelevoOverviewEvent() + data object StopPumpFailed : CarelevoOverviewEvent() + + data object ClickPumpStopResumeBtn : CarelevoOverviewEvent() + data object ShowPumpStopDurationSelectDialog : CarelevoOverviewEvent() + data object ShowPumpResumeDialog : CarelevoOverviewEvent() + + // Action requests emitted from PumpAction.onClick (handled by the screen) + data object StartConnectionFlow : CarelevoOverviewEvent() + data object ShowPumpDiscardDialog : CarelevoOverviewEvent() +} + +sealed class CarelevoConnectEvent : Event { + + data object NoAction : CarelevoConnectEvent() + data object DiscardComplete : CarelevoConnectEvent() + data object DiscardFailed : CarelevoConnectEvent() + data object ExitFlow : CarelevoConnectEvent() + +} + +sealed class CarelevoConnectPrepareEvent : Event { + + data object NoAction : CarelevoConnectPrepareEvent() + data object ShowConnectDialog : CarelevoConnectPrepareEvent() + data object ShowMessageScanFailed : CarelevoConnectPrepareEvent() + data object ShowMessageBluetoothNotEnabled : CarelevoConnectPrepareEvent() + data object ShowMessageScanIsWorking : CarelevoConnectPrepareEvent() + data object ShowMessageSelectedDeviceIseEmpty : CarelevoConnectPrepareEvent() + data object ShowMessageNotSetUserSettingInfo : CarelevoConnectPrepareEvent() + + data object ConnectComplete : CarelevoConnectPrepareEvent() + data object ConnectFailed : CarelevoConnectPrepareEvent() + + data object DiscardComplete : CarelevoConnectPrepareEvent() + data object DiscardFailed : CarelevoConnectPrepareEvent() +} + +sealed class CarelevoConnectSafetyCheckEvent : Event { + + data object NoAction : CarelevoConnectSafetyCheckEvent() + data object ShowMessageBluetoothNotEnabled : CarelevoConnectSafetyCheckEvent() + data object ShowMessageCarelevoIsNotConnected : CarelevoConnectSafetyCheckEvent() + data object SafetyCheckProgress : CarelevoConnectSafetyCheckEvent() + data object SafetyCheckComplete : CarelevoConnectSafetyCheckEvent() + data object SafetyCheckFailed : CarelevoConnectSafetyCheckEvent() + data object DiscardComplete : CarelevoConnectSafetyCheckEvent() + data object DiscardFailed : CarelevoConnectSafetyCheckEvent() + +} + +sealed class CarelevoConnectNeedleEvent : Event { + + data object NoAction : CarelevoConnectNeedleEvent() + data object ShowMessageBluetoothNotEnabled : CarelevoConnectNeedleEvent() + data object ShowMessageCarelevoIsNotConnected : CarelevoConnectNeedleEvent() + data object ShowMessageProfileNotSet : CarelevoConnectNeedleEvent() + data class CheckNeedleComplete(val result: Boolean) : CarelevoConnectNeedleEvent() + data class CheckNeedleFailed(val failedCount: Int) : CarelevoConnectNeedleEvent() + data object CheckNeedleError : CarelevoConnectNeedleEvent() + data object DiscardComplete : CarelevoConnectNeedleEvent() + data object DiscardFailed : CarelevoConnectNeedleEvent() + data object SetBasalComplete : CarelevoConnectNeedleEvent() + data object SetBasalFailed : CarelevoConnectNeedleEvent() +} + +sealed class AlarmEvent : Event { + data object NoAction : AlarmEvent() + data class ClearAlarm(val info: CarelevoAlarmInfo) : AlarmEvent() + data object RequestBluetoothEnable : AlarmEvent() + data class ShowToastMessage( + @StringRes val messageRes: Int + ) : AlarmEvent() + + data object Mute : AlarmEvent() + data object Mute5min : AlarmEvent() + data object StartAlarm : AlarmEvent() +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/type/CarelevoPatchStep.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/type/CarelevoPatchStep.kt new file mode 100644 index 000000000000..0baf3a835cde --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/type/CarelevoPatchStep.kt @@ -0,0 +1,13 @@ +package app.aaps.pump.carelevo.presentation.type + +enum class CarelevoPatchStep { + PROFILE_GATE, + SELECT_INSULIN, + PATCH_START, + SET_AMOUNT, + PATCH_CONNECT, + SAFETY_CHECK, + SITE_LOCATION, + PATCH_ATTACH, + NEEDLE_INSERTION +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/type/CarelevoScreenType.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/type/CarelevoScreenType.kt new file mode 100644 index 000000000000..1035606fc946 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/type/CarelevoScreenType.kt @@ -0,0 +1,8 @@ +package app.aaps.pump.carelevo.presentation.type + +enum class CarelevoScreenType { + CONNECTION_FLOW_START, + PATCH_DISCARD, + SAFETY_CHECK, + NEEDLE_INSERTION, +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoAlarmViewModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoAlarmViewModel.kt new file mode 100644 index 000000000000..e1f63683b522 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoAlarmViewModel.kt @@ -0,0 +1,87 @@ +package app.aaps.pump.carelevo.presentation.viewmodel + +import android.os.Handler +import android.os.HandlerThread +import androidx.lifecycle.ViewModel +import app.aaps.core.data.time.T +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.ui.UiInteraction +import app.aaps.core.ui.R as CoreUiR +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.common.CarelevoAlarmActionHandler +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.ext.transformNotificationStringResources +import app.aaps.pump.carelevo.presentation.model.AlarmEvent +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * UI shell over [CarelevoAlarmActionHandler] for the in-app full-screen alarm: owns ONLY the sound + * lifecycle (start/stop/mute/mute-5-min) and forwards clear requests to the handler's shared + * state machine. The alarm queue, its empty event, and UI requests (Bluetooth-enable intent, + * failure toast) are the handler's — exposed here unchanged so the Compose host has one ViewModel + * to talk to. + */ +@HiltViewModel +class CarelevoAlarmViewModel @Inject constructor( + private val aapsLogger: AAPSLogger, + private val uiInteraction: UiInteraction, + private val rh: ResourceHelper, + private val alarmActionHandler: CarelevoAlarmActionHandler +) : ViewModel() { + + val alarmQueue = alarmActionHandler.alarmQueue + val alarmQueueEmptyEvent = alarmActionHandler.alarmQueueEmptyEvent + val event = alarmActionHandler.uiRequests + + /** The alarm the host is currently presenting; used for the alarm-dialog status text. */ + var alarmInfo: CarelevoAlarmInfo? = null + + private val sound = CoreUiR.raw.error + private var handler = Handler(HandlerThread(this::class.simpleName + "Handler").also { it.start() }.looper) + + private fun startAlarm(reason: String) { + if (sound == 0) return + val title = rh.gs(R.string.carelevo) + val status = alarmInfo?.let { rh.gs(it.cause.transformNotificationStringResources().first) } ?: title + aapsLogger.debug(LTag.PUMPCOMM, "startAlarm reason=$reason status=$status") + uiInteraction.runAlarm(status, title, sound) + } + + private fun stopAlarm(reason: String) { + uiInteraction.stopAlarm(reason) + } + + fun triggerEvent(event: AlarmEvent) { + when (event) { + is AlarmEvent.ClearAlarm -> { + stopAlarm("Confirm Click") + alarmInfo = event.info + alarmActionHandler.triggerEvent(event) + } + + is AlarmEvent.Mute -> stopAlarm("Mute Click") + + is AlarmEvent.Mute5min -> { + stopAlarm("Mute5min Click") + handler.postDelayed({ startAlarm("post") }, T.mins(5).msecs()) + } + + is AlarmEvent.StartAlarm -> startAlarm("start") + else -> Unit + } + } + + fun loadActiveAlarms() = alarmActionHandler.loadActiveAlarms() + + override fun onCleared() { + super.onCleared() + // Drop any pending Mute5min re-arm and stop the dedicated thread — without this the + // HandlerThread leaks for the process lifetime and a delayed startAlarm could fire + // against a cleared ViewModel. + handler.removeCallbacksAndMessages(null) + handler.looper.quitSafely() + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoOverviewViewModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoOverviewViewModel.kt new file mode 100644 index 000000000000..6bd48d444ab6 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoOverviewViewModel.kt @@ -0,0 +1,938 @@ +package app.aaps.pump.carelevo.presentation.viewmodel + +import android.content.Context +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.SwapHoriz +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.aaps.core.data.pump.defs.PumpType +import app.aaps.core.data.time.T +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.pump.PumpRate +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.ui.compose.StatusLevel +import app.aaps.core.ui.compose.icons.IcLoopPaused +import app.aaps.core.ui.compose.pump.ActionCategory +import app.aaps.core.ui.compose.pump.PumpAction +import app.aaps.core.ui.compose.pump.PumpCommunicationStatus +import app.aaps.core.ui.compose.pump.PumpInfoRow +import app.aaps.core.ui.compose.pump.PumpOverviewUiState +import app.aaps.core.ui.compose.pump.StatusBanner +import app.aaps.core.ui.compose.pump.tickerFlow +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.ble.CarelevoBleSession +import app.aaps.pump.carelevo.command.CmdDiscard +import app.aaps.pump.carelevo.command.CmdPumpResume +import app.aaps.pump.carelevo.command.CmdPumpStop +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.common.MutableEventFlow +import app.aaps.pump.carelevo.common.asEventFlow +import app.aaps.pump.carelevo.common.model.Event +import app.aaps.pump.carelevo.common.model.PatchState +import app.aaps.pump.carelevo.common.model.State +import app.aaps.pump.carelevo.common.model.UiState +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.usecase.infusion.CarelevoDeleteInfusionInfoUseCase +import app.aaps.pump.carelevo.domain.usecase.infusion.model.CarelevoDeleteInfusionRequestModel +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchForceDiscardUseCase +import app.aaps.pump.carelevo.presentation.model.CarelevoOverviewEvent +import app.aaps.pump.carelevo.presentation.model.CarelevoOverviewUiModel +import app.aaps.pump.carelevo.presentation.type.CarelevoScreenType +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.joda.time.DateTime +import java.math.RoundingMode +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.Locale +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull +import app.aaps.core.ui.R as CoreUiR + +@HiltViewModel +class CarelevoOverviewViewModel @Inject constructor( + private val rh: ResourceHelper, + private val pumpSync: PumpSync, + private val dateUtil: DateUtil, + private val commandQueue: CommandQueue, + private val aapsLogger: AAPSLogger, + private val carelevoPatch: CarelevoPatch, + private val bleSession: CarelevoBleSession, + private val aapsSchedulers: AapsSchedulers, + private val patchForceDiscardUseCase: CarelevoPatchForceDiscardUseCase, + private val carelevoDeleteInfusionInfoUseCase: CarelevoDeleteInfusionInfoUseCase, + private val rxBus: RxBus, + @ApplicationContext private val context: Context +) : ViewModel() { + + private val _patchState = MutableLiveData(PatchState.NotConnectedNotBooting) + val patchState: LiveData get() = _patchState + + private val _serialNumber = MutableLiveData() + val serialNumber get() = _serialNumber + + private val _lotNumber = MutableLiveData() + val lotNumber get() = _lotNumber + + private val _bootDateTime = MutableLiveData() + val bootDateTime get() = _bootDateTime + + private val _expirationTime = MutableLiveData() + val expirationTime get() = _expirationTime + + private val _basalRate = MutableLiveData() + val basalRate get() = _basalRate + + private val _tempBasalRate = MutableLiveData() + val tempBasalRate get() = _tempBasalRate + + private val _insulinRemains = MutableLiveData() + val insulinRemains get() = _insulinRemains + + private val _totalInsulinAmount = MutableLiveData() + val totalInsulinAmount get() = _totalInsulinAmount + + private val _runningRemainMinutes = MutableLiveData() + val runningRemainMinutes get() = _runningRemainMinutes + + private var _isCreated = false + val isCreated get() = _isCreated + + private val _event = MutableEventFlow() + val event = _event.asEventFlow() + + private val _uiState: MutableStateFlow = MutableStateFlow(UiState.Idle) + val uiState = _uiState.asStateFlow() + + private val _patchStateFlow = MutableStateFlow(PatchState.NotConnectedNotBooting) + private val _overviewDataFlow = MutableStateFlow(defaultOverviewData()) + private val _basalRateFlow = MutableStateFlow(0.0) + private val _tempBasalRateFlow = MutableStateFlow(null) + + private val communicationStatus = PumpCommunicationStatus(rxBus, commandQueue, context, viewModelScope) + + private val connectionInfo = combine( + bleSession.connected, + bleSession.lastConnectedAt + ) { connected, lastConnectedAt -> ConnectionInfo(connected, lastConnectedAt) } + + private val overviewInputs = combine( + _patchStateFlow, + _overviewDataFlow, + _basalRateFlow, + _tempBasalRateFlow, + connectionInfo + ) { patchState, overviewData, basalRate, tempBasalRate, connection -> + OverviewInputs( + patchState = patchState, + overviewData = overviewData, + basalRate = basalRate, + tempBasalRate = tempBasalRate, + connected = connection.connected, + lastConnectedAt = connection.lastConnectedAt + ) + } + + val overviewUiState = combine( + overviewInputs, + communicationStatus.refreshTrigger, + tickerFlow(30_000L) + ) { inputs, _, _ -> + buildOverviewState( + patchState = inputs.patchState, + overviewData = inputs.overviewData, + basalRate = inputs.basalRate, + tempBasalRate = inputs.tempBasalRate, + connected = inputs.connected, + lastConnectedAt = inputs.lastConnectedAt + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = buildOverviewState( + patchState = _patchStateFlow.value, + overviewData = _overviewDataFlow.value, + basalRate = _basalRateFlow.value, + tempBasalRate = _tempBasalRateFlow.value, + connected = bleSession.connected.value, + lastConnectedAt = bleSession.lastConnectedAt.value + ) + ) + + private var _isPumpStop = MutableLiveData(false) + val isPumpStop get() = _isPumpStop + + private var _isCheckScreen = MutableStateFlow(null) + val isCheckScreen get() = _isCheckScreen + + private val _hasUnacknowledgedAlarms = MutableStateFlow(false) + val hasUnacknowledgedAlarms = _hasUnacknowledgedAlarms.asStateFlow() + + private val compositeDisposable = CompositeDisposable() + + val secondTick: Flow = flow { + while (currentCoroutineContext().isActive) { + val now = DateTime.now() + emit(now) + delay((1000 - now.millisOfSecond).coerceIn(1, 1000).toLong()) + } + }.flowOn(Dispatchers.Default) + + init { + viewModelScope.launch { + secondTick.collect { + clearExpiredInfusions() + } + } + } + + fun setIsCreated(isCreated: Boolean) { + _isCreated = isCreated + } + + fun observePatchInfo() { + compositeDisposable += carelevoPatch.patchInfo + .observeOn(aapsSchedulers.io) + .flatMap { info -> + val patchInfo = info?.getOrNull() + if (patchInfo == null) { + aapsLogger.debug(LTag.PUMPCOMM, "[observePatchInfo] skip null/failure") + _isCheckScreen.tryEmit(null) + Observable.empty() + } else { + aapsLogger.debug(LTag.PUMPCOMM, "[observePatchInfo] state: $patchInfo") + updateCheckScreen(patchInfo) + Observable.just(buildUi(patchInfo)) + } + } + .observeOn(aapsSchedulers.main) + .doOnNext { ui -> updateState(ui) } + .subscribe( + { ui -> + aapsLogger.debug(LTag.PUMPCOMM, "state : $ui") + }, + { e -> + aapsLogger.debug(LTag.PUMPCOMM, "onError", e) + } + ) + } + + private fun updateCheckScreen(patchInfo: CarelevoPatchInfoDomainModel) { + val screenType = when { + patchInfo.checkNeedle == false -> { + val count = patchInfo.needleFailedCount + if (count != null && count < 3) CarelevoScreenType.NEEDLE_INSERTION else null + } + + patchInfo.checkSafety == null -> CarelevoScreenType.SAFETY_CHECK + patchInfo.checkSafety && patchInfo.checkNeedle == null -> CarelevoScreenType.SAFETY_CHECK + else -> null + } + _isCheckScreen.tryEmit(screenType) + } + + private fun updateState(ui: CarelevoOverviewUiModel) { + _overviewDataFlow.value = ui + _serialNumber.value = ui.serialNumber + _lotNumber.value = ui.lotNumber + _bootDateTime.value = ui.bootDateTimeUi + _expirationTime.value = ui.expirationTime + //_infusionStatus.value = ui.infusionStatus + _insulinRemains.value = ui.insulinRemainText + _totalInsulinAmount.value = String.format(Locale.US, "%.2f", ui.totalBasal + ui.totalBolus).toDouble() + _isPumpStop.value = ui.isPumpStopped + _runningRemainMinutes.value = ui.runningRemainMinutes + } + + private fun buildUi(info: CarelevoPatchInfoDomainModel): CarelevoOverviewUiModel { + aapsLogger.debug(LTag.PUMPCOMM, "info : $info") + val bootLdt = parseBootDateTime(info.bootDateTimeUtcMillis) ?: parseBootDateTime(info.bootDateTime) + val bootUi = bootLdt?.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) ?: "" + + val infusedBasal = (info.infusedTotalBasalAmount ?: 0.0) + .toBigDecimal().setScale(2, RoundingMode.HALF_UP).toDouble() + val infusedBolus = (info.infusedTotalBolusAmount ?: 0.0) + .toBigDecimal().setScale(2, RoundingMode.HALF_UP).toDouble() + + val remainMinutes = bootLdt?.let { getRemainMin(it) } ?: 0 + val expireAt = bootLdt?.let { getExpireAtText(it) } ?: "" + + return CarelevoOverviewUiModel( + serialNumber = info.manufactureNumber.orEmpty(), + lotNumber = info.firmwareVersion.orEmpty(), + bootDateTimeUi = bootUi, + expirationTime = expireAt, + infusionStatus = info.mode, + insulinRemainText = if (info.insulinRemain != null && info.insulinAmount != null) + rh.gs( + R.string.carelevo_insulin_remain_value, + rh.gs(R.string.common_label_unit_value_dose_with_space, info.insulinRemain), + rh.gs(R.string.common_label_unit_value_dose_with_space, info.insulinAmount) + ) + else "", + totalBasal = infusedBasal, + totalBolus = infusedBolus, + isPumpStopped = info.isStopped ?: false, + runningRemainMinutes = remainMinutes + ) + } + + fun observePatchState() { + compositeDisposable += carelevoPatch.patchState + .observeOn(aapsSchedulers.main) + .subscribe( + { response -> + aapsLogger.debug(LTag.PUMPCOMM, "state : ${response.getOrNull()}") + response?.getOrNull()?.let { patchState -> + _patchState.value = patchState + _patchStateFlow.value = patchState + if (patchState == PatchState.NotConnectedNotBooting) { + onDisconnectValue() + } else { + val basalRate = carelevoPatch.profile.value?.getOrNull()?.getBasal() ?: 0.0 + _basalRate.value = basalRate + _basalRateFlow.value = basalRate + } + } + }, + { + aapsLogger.debug(LTag.PUMPCOMM, "doOnError called : $it") + } + ) + } + + fun observeInfusionInfo() { + compositeDisposable += carelevoPatch.infusionInfo + .subscribeOn(aapsSchedulers.io) + .observeOn(aapsSchedulers.main) + .subscribe { + val infusionInfo = it.getOrNull() ?: run { + val patchInfo = carelevoPatch.patchInfo.value?.getOrNull() ?: return@subscribe + if (patchInfo.checkNeedle == true) { + _isCheckScreen.tryEmit(CarelevoScreenType.NEEDLE_INSERTION) + } + return@subscribe + } + handleInfusionProgram(infusionInfo) + } + } + + private fun handleInfusionProgram(info: CarelevoInfusionInfoDomainModel) { + val temp = info.tempBasalInfusionInfo + _tempBasalRate.value = temp?.speed + _tempBasalRateFlow.value = temp?.speed + } + + private fun clearExpiredInfusions() { + val infusionInfo = carelevoPatch.infusionInfo.value?.getOrNull() ?: return + val tempBasalInfusionInfo = infusionInfo.tempBasalInfusionInfo + val immeBolusInfusionInfo = infusionInfo.immeBolusInfusionInfo + val extendBolusInfusionInfo = infusionInfo.extendBolusInfusionInfo + + val now = DateTime.now() + + val tempBasal = tempBasalInfusionInfo?.takeIf { infusion -> + val duration = infusion.infusionDurationMin ?: return@takeIf true + val endTime = infusion.createdAt.plusMinutes(duration) + endTime.isAfter(now) + } + + val immeBolus = immeBolusInfusionInfo?.takeIf { infusion -> + val duration = infusion.infusionDurationSeconds ?: return@takeIf true + val endTime = infusion.createdAt.plusSeconds(duration) + endTime.isAfter(now) + } + + val extendBolus = extendBolusInfusionInfo?.takeIf { infusion -> + val duration = infusion.infusionDurationMin ?: return@takeIf true + val endTime = infusion.createdAt.plusMinutes(duration) + endTime.isAfter(now) + } + + val deleteTemp = (infusionInfo.tempBasalInfusionInfo != null && tempBasal == null) + val deleteImme = (infusionInfo.immeBolusInfusionInfo != null && immeBolus == null) + val deleteExtend = (infusionInfo.extendBolusInfusionInfo != null && extendBolus == null) + + if (!deleteTemp && !deleteImme && !deleteExtend) return + + val requestModel = CarelevoDeleteInfusionRequestModel( + isDeleteTempBasal = deleteTemp, + isDeleteImmeBolus = deleteImme, + isDeleteExtendBolus = deleteExtend + ) + clearInfusionInfo(requestModel) + } + + fun clearInfusionInfo(requestModel: CarelevoDeleteInfusionRequestModel) { + compositeDisposable += carelevoDeleteInfusionInfoUseCase.execute(requestModel) + .subscribeOn(aapsSchedulers.io) + .observeOn(aapsSchedulers.main) + .subscribe( + { optionalList -> + aapsLogger.debug(LTag.PUMPCOMM, "success") + refreshPatchInfusionInfo() + }, { e -> + aapsLogger.debug(LTag.PUMPCOMM, "error : $e") + }) + } + + fun observeProfile() { + compositeDisposable += carelevoPatch.profile + .observeOn(aapsSchedulers.main) + .subscribe { + val basalRate = it?.getOrNull()?.getBasal() ?: 0.0 + _basalRate.value = basalRate + _basalRateFlow.value = basalRate + } + } + + fun initUnacknowledgedAlarms() { + _hasUnacknowledgedAlarms.value = false + } + + fun triggerEvent(event: Event) { + viewModelScope.launch { + when (event) { + is CarelevoOverviewEvent -> generateEventType(event).run { _event.emit(this) } + } + } + } + + private fun generateEventType(event: Event): Event { + return when (event) { + is CarelevoOverviewEvent.ShowMessageBluetoothNotEnabled -> event + is CarelevoOverviewEvent.ShowMessageCarelevoIsNotConnected -> event + is CarelevoOverviewEvent.DiscardFailed -> event + is CarelevoOverviewEvent.ResumePumpFailed -> event + is CarelevoOverviewEvent.StopPumpFailed -> event + is CarelevoOverviewEvent.StartConnectionFlow -> event + is CarelevoOverviewEvent.ShowPumpDiscardDialog -> event + + is CarelevoOverviewEvent.ClickPumpStopResumeBtn -> { + resolvePumpStopResumeEvent() + } + + else -> CarelevoOverviewEvent.NoAction + } + } + + private fun resolvePumpStopResumeEvent(): CarelevoOverviewEvent { + return when (carelevoPatch.resolvePatchState()) { + is PatchState.NotConnectedNotBooting -> { + CarelevoOverviewEvent.ShowMessageCarelevoIsNotConnected + } + + else -> { + val isStop = carelevoPatch.patchInfo.value?.getOrNull()?.isStopped ?: false + if (isStop) { + CarelevoOverviewEvent.ShowPumpResumeDialog + } else { + CarelevoOverviewEvent.ShowPumpStopDurationSelectDialog + } + } + } + } + + private fun setUiState(state: State) { + viewModelScope.launch { + _uiState.tryEmit(state) + } + } + + fun startDiscardProcess() { + when (carelevoPatch.patchState.value?.getOrNull()) { + is PatchState.NotConnectedNotBooting, null -> Unit // no active patch → nothing to discard + + else -> { + // Route the BLE stop through the queue (reconnect-before-execute); if the patch can't + // be reached at all, fall back to the DB-only force-discard. + setUiState(UiState.Loading) + viewModelScope.launch { + val result = commandQueue.customCommand(CmdDiscard()) + if (result.success) { + aapsLogger.debug(LTag.PUMPCOMM, "[startDiscard] success") + // unBond + releasePatch run inside CmdDiscard on the queue thread + setUiState(UiState.Idle) + } else { + aapsLogger.error(LTag.PUMPCOMM, "[startDiscard] failed, falling back to force-discard") + startPatchForceDiscard() + } + } + } + } + } + + private fun handlePatchDiscardResponse(response: ResponseResult<*>) { + when (response) { + is ResponseResult.Success -> { + aapsLogger.debug(LTag.PUMPCOMM, "[startPatchDiscard] success") + carelevoPatch.discardTeardown() + } + + else -> { + aapsLogger.debug(LTag.PUMPCOMM, "[startPatchDiscard] failed or error") + triggerEvent(CarelevoOverviewEvent.DiscardFailed) + } + } + setUiState(UiState.Idle) + } + + private fun handlePatchDiscardError(error: Throwable) { + aapsLogger.debug(LTag.PUMPCOMM, "[startPatchDiscard] error: $error") + setUiState(UiState.Idle) + triggerEvent(CarelevoOverviewEvent.DiscardFailed) + } + + private fun startPatchForceDiscard() { + setUiState(UiState.Loading) + compositeDisposable += patchForceDiscardUseCase.execute() + .timeout(10, TimeUnit.SECONDS) + .subscribeOn(aapsSchedulers.io) + .observeOn(aapsSchedulers.main) + .subscribe( + { response -> handlePatchDiscardResponse(response) }, + { error -> handlePatchDiscardError(error) } + ) + } + + fun startPumpStopProcess(stopMinute: Int) { + if (!carelevoPatch.isBluetoothEnabled()) { + triggerEvent(CarelevoOverviewEvent.ShowMessageBluetoothNotEnabled) + return + } + + setUiState(UiState.Loading) + + val infusionInfo = carelevoPatch.infusionInfo.value?.getOrNull() + val isExtendBolusRunning = infusionInfo?.extendBolusInfusionInfo != null + val isTempBasalRunning = infusionInfo?.tempBasalInfusionInfo != null + + viewModelScope.launch { + val cancelExtendBolusResult = if (isExtendBolusRunning) { + cancelExtendBolus() + } else { + true + } + val cancelTempBasalResult = if (isTempBasalRunning) { + cancelTempBasal() + } else { + true + } + + aapsLogger.debug(LTag.PUMPCOMM, "[startPumpStopProcess] isTempBasalRunning=$cancelTempBasalResult, isExtendBolusRunning=$cancelExtendBolusResult, stopMinute: $stopMinute") + + if (cancelExtendBolusResult && cancelTempBasalResult) { + // Route the stop frame through the queue (connect-before-execute). The pre-cancel above + // already ran on the queue from this coroutine (safe — not the worker thread). + val result = commandQueue.customCommand(CmdPumpStop(stopMinute)) + setUiState(UiState.Idle) + if (result.success) { + handlePumpStopResponse( + isTempBasalRunning = isTempBasalRunning, + isExtendBolusRunning = isExtendBolusRunning, + stopMinute = stopMinute + ) + } else { + aapsLogger.debug(LTag.PUMPCOMM, "[startPumpStopProcess] stop failed") + triggerEvent(CarelevoOverviewEvent.StopPumpFailed) + } + } else { + aapsLogger.debug(LTag.PUMPCOMM, "[startPumpStopProcess] no active temp/extend bolus to cancel") + setUiState(UiState.Idle) + triggerEvent(CarelevoOverviewEvent.StopPumpFailed) + } + } + } + + private fun handlePumpStopResponse( + isTempBasalRunning: Boolean, + isExtendBolusRunning: Boolean, + stopMinute: Int + ) { + aapsLogger.debug(LTag.PUMPCOMM, "[startPumpStopProcess] response success") + + viewModelScope.launch { + pumpSync.syncTemporaryBasalWithPumpId( + timestamp = dateUtil.now(), + rate = PumpRate(0.0), + duration = T.mins(stopMinute.toLong()).msecs(), + isAbsolute = true, + type = PumpSync.TemporaryBasalType.PUMP_SUSPEND, + pumpId = dateUtil.now(), + pumpType = PumpType.CAREMEDI_CARELEVO, + pumpSerial = carelevoPatch.patchInfo.value?.getOrNull()?.manufactureNumber ?: "" + ) + + pumpSync.syncStopExtendedBolusWithPumpId( + timestamp = dateUtil.now(), + endPumpId = dateUtil.now(), + pumpType = PumpType.CAREMEDI_CARELEVO, + pumpSerial = carelevoPatch.patchInfo.value?.getOrNull()?.manufactureNumber ?: "" + ) + } + + clearInfusionInfo( + CarelevoDeleteInfusionRequestModel( + isDeleteTempBasal = isTempBasalRunning, + isDeleteImmeBolus = false, + isDeleteExtendBolus = isExtendBolusRunning + ) + ) + } + + private suspend fun cancelTempBasal(): Boolean { + return commandQueue.cancelTempBasal(true).success + } + + private suspend fun cancelExtendBolus(): Boolean { + return commandQueue.cancelExtended().success + } + + fun startPumpResume() { + if (!carelevoPatch.isBluetoothEnabled()) { + triggerEvent(CarelevoOverviewEvent.ShowMessageBluetoothNotEnabled) + return + } + + setUiState(UiState.Loading) + viewModelScope.launch { + // Route the resume frame through the queue (connect-before-execute). + val result = commandQueue.customCommand(CmdPumpResume()) + setUiState(UiState.Idle) + if (result.success) { + pumpSync.syncStopTemporaryBasalWithPumpId( + timestamp = dateUtil.now(), + endPumpId = dateUtil.now(), + pumpType = PumpType.CAREMEDI_CARELEVO, + pumpSerial = carelevoPatch.patchInfo.value?.getOrNull()?.manufactureNumber ?: "" + ) + } else { + aapsLogger.debug(LTag.PUMPCOMM, "[startPumpResume] resume failed") + triggerEvent(CarelevoOverviewEvent.ResumePumpFailed) + } + } + } + + fun parseBootDateTime(raw: String?): LocalDateTime? { + if (raw.isNullOrBlank()) { + return null + } + return try { + val formatter = DateTimeFormatter.ofPattern("yyMMddHHmm") + LocalDateTime.parse(raw, formatter) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + fun parseBootDateTime(utcMillis: Long?): LocalDateTime? { + if (utcMillis == null) { + return null + } + + return runCatching { + LocalDateTime.ofInstant(Instant.ofEpochMilli(utcMillis), ZoneId.systemDefault()) + }.getOrNull() + } + + private fun onDisconnectValue() { + _overviewDataFlow.value = defaultOverviewData() + _serialNumber.value = "" + _lotNumber.value = "" + _bootDateTime.value = "" + _expirationTime.value = "" + _insulinRemains.value = "" + _totalInsulinAmount.value = 0.0 + _isPumpStop.value = false + _runningRemainMinutes.value = 0 + _tempBasalRate.value = null + _tempBasalRateFlow.value = null + _basalRate.value = 0.0 + _basalRateFlow.value = 0.0 + } + + private fun defaultOverviewData(): CarelevoOverviewUiModel = CarelevoOverviewUiModel( + serialNumber = "", + lotNumber = "", + bootDateTimeUi = "", + expirationTime = "", + infusionStatus = null, + insulinRemainText = "", + totalBasal = 0.0, + totalBolus = 0.0, + isPumpStopped = false, + runningRemainMinutes = 0 + ) + + private fun buildOverviewState( + patchState: PatchState?, + overviewData: CarelevoOverviewUiModel, + basalRate: Double, + tempBasalRate: Double?, + connected: Boolean, + lastConnectedAt: Long + ): PumpOverviewUiState { + // A patch is activated (connected OR just idle-disconnected) → no warning banner; let the shared + // communication/queue status surface (matches Medtrum/Equil). Idle-disconnect is normal now that + // the queue owns the lifecycle and reconnects on demand; only "no active patch" and "suspended" + // are real top-status conditions. + val banner = when { + patchState == PatchState.NotConnectedNotBooting -> StatusBanner( + text = rh.gs(R.string.carelevo_state_none_value), + level = StatusLevel.WARNING + ) + + // Suspended outranks the (idle) connection status — delivery is paused (matches Medtrum). + overviewData.isPumpStopped -> StatusBanner( + text = rh.gs(CoreUiR.string.pumpsuspended), + level = StatusLevel.WARNING + ) + + else -> null + } ?: communicationStatus.statusBanner() + + val infoRows = buildList { + add( + PumpInfoRow( + label = rh.gs(R.string.carelevo_bluetooth_state_key), + value = connectionLabel(patchState, connected) + ) + ) + + when (patchState) { + // Patch activated (connected or idle-disconnected) → show the full status. Idle-disconnect + // is normal; values are last-known and the queue reconnects on the next action. + PatchState.ConnectedBooted, + PatchState.NotConnectedBooted -> { + add( + PumpInfoRow( + label = rh.gs(CoreUiR.string.last_connection_label), + value = lastConnectionLabel(lastConnectedAt) + ) + ) + add( + PumpInfoRow( + label = rh.gs(R.string.carelevo_serial_number_key), + value = overviewData.serialNumber.ifBlank { "-" } + ) + ) + add( + PumpInfoRow( + label = rh.gs(R.string.carelevo_firmware_version_key), + value = overviewData.lotNumber.ifBlank { "-" } + ) + ) + add( + PumpInfoRow( + label = rh.gs(R.string.carelevo_boot_date_time_key), + value = overviewData.bootDateTimeUi.ifBlank { "-" } + ) + ) + add( + PumpInfoRow( + label = rh.gs(R.string.carelevo_expiration_key), + value = overviewData.expirationTime.ifBlank { "-" } + ) + ) + add( + PumpInfoRow( + label = rh.gs(R.string.carelevo_running_remain_time), + value = formatRemainingMinutes(overviewData.runningRemainMinutes) + ) + ) + add( + PumpInfoRow( + label = rh.gs(R.string.carelevo_basal_rate_key), + value = rh.gs(R.string.common_label_unit_value_dose_per_speed_with_space, basalRate) + ) + ) + add( + PumpInfoRow( + label = rh.gs(R.string.carelevo_temp_basal_rate_key), + value = rh.gs(R.string.common_label_unit_value_dose_per_speed_with_space, tempBasalRate ?: 0.0) + ) + ) + add( + PumpInfoRow( + label = rh.gs(R.string.carelevo_insulin_remain_key), + value = overviewData.insulinRemainText.ifBlank { "-" } + ) + ) + add( + PumpInfoRow( + label = rh.gs(R.string.carelevo_total_insulin_key), + value = rh.gs( + R.string.common_label_unit_value_dose_with_space, + String.format(Locale.US, "%.2f", overviewData.totalBasal + overviewData.totalBolus) + ) + ) + ) + } + + else -> Unit + } + } + + val primaryActions = when (patchState) { + PatchState.NotConnectedNotBooting -> listOf( + PumpAction( + label = rh.gs(R.string.carelevo_overview_connect_btn_label), + icon = Icons.Filled.SwapHoriz, + category = ActionCategory.PRIMARY, + onClick = { triggerEvent(CarelevoOverviewEvent.StartConnectionFlow) } + ) + ) + + else -> emptyList() + } + + // Suspend/Discard are available whenever a patch is activated (connected or idle-disconnected); + // they reconnect-on-tap via the queue rather than being gated behind a live link. + val managementActions = if (patchState == PatchState.ConnectedBooted || patchState == PatchState.NotConnectedBooted) { + listOf( + PumpAction( + label = rh.gs(R.string.carelevo_overview_pump_discard_btn_label), + icon = Icons.Filled.Delete, + category = ActionCategory.MANAGEMENT, + onClick = { triggerEvent(CarelevoOverviewEvent.ShowPumpDiscardDialog) } + ), + PumpAction( + label = if (overviewData.isPumpStopped) { + rh.gs(CoreUiR.string.pump_resume) + } else { + rh.gs(CoreUiR.string.pump_suspend) + }, + icon = if (overviewData.isPumpStopped) Icons.Filled.PlayArrow else IcLoopPaused, + category = ActionCategory.MANAGEMENT, + onClick = { triggerEvent(CarelevoOverviewEvent.ClickPumpStopResumeBtn) } + ) + ) + } else { + emptyList() + } + + return PumpOverviewUiState( + statusBanner = banner, + queueStatus = communicationStatus.queueStatus(), + infoRows = infoRows, + primaryActions = primaryActions, + managementActions = managementActions + ) + } + + /** Live connection label: Connected only while a session is open, Disconnected when idle, none when no patch. */ + private fun connectionLabel(patchState: PatchState?, connected: Boolean): String = when { + patchState == PatchState.NotConnectedNotBooting -> rh.gs(R.string.carelevo_state_none_value) + connected -> rh.gs(R.string.carelevo_state_connected_value) + else -> rh.gs(R.string.carelevo_state_disconnected_value) + } + + /** "Last connection: X ago" reachability value via the shared [DateUtil.minAgo] (matching other pumps); "-" until the first connection. */ + private fun lastConnectionLabel(lastConnectedAt: Long): String = + if (lastConnectedAt <= 0L) "-" else dateUtil.minAgo(rh, lastConnectedAt) + + private fun formatRemainingMinutes(totalMinutes: Int): String { + if (totalMinutes <= 0) return "-" + + val days = totalMinutes / 1440 + val remainingMinutesAfterDays = totalMinutes % 1440 + val hours = remainingMinutesAfterDays / 60 + val minutes = remainingMinutesAfterDays % 60 + + return if (days > 0) { + rh.gs(R.string.common_unit_value_day_hour_min, days, hours, minutes) + } else { + String.format(Locale.getDefault(), "%02d:%02d", hours, minutes) + } + } + + private data class OverviewInputs( + val patchState: PatchState, + val overviewData: CarelevoOverviewUiModel, + val basalRate: Double, + val tempBasalRate: Double?, + val connected: Boolean, + val lastConnectedAt: Long + ) + + private data class ConnectionInfo(val connected: Boolean, val lastConnectedAt: Long) + + /** Canonical clock for the expiry countdown — [dateUtil] so tests can inject a fake time. */ + private fun nowLocal(): LocalDateTime = + LocalDateTime.ofInstant(Instant.ofEpochMilli(dateUtil.now()), ZoneId.systemDefault()) + + private fun getRemainMin(createdAt: LocalDateTime): Int { + val endAt = createdAt.plusDays(7) + val now = nowLocal() + var remainMin = ChronoUnit.MINUTES.between(now, endAt) + + if (now.isAfter(endAt)) { + remainMin = ChronoUnit.MINUTES.between(endAt, now) + } + + return remainMin.toInt() + } + + private fun getExpireAtText(createdAt: LocalDateTime): String { + val now = nowLocal() + val baseEnd = createdAt.plusDays(7) + + val expireAt = if (now.isAfter(baseEnd)) { + baseEnd.plusHours(12) + } else { + baseEnd + } + + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + return expireAt.format(formatter) + } + + fun refreshPatchInfusionInfo() { + if (!carelevoPatch.isBluetoothEnabled()) { + return + } + // Route through the queue: connect-before-read so an idle-disconnected patch reconnects, + // reads status, then the queue idle-disconnects. + viewModelScope.launch { + commandQueue.readStatus("Carelevo overview refresh") + } + } + + override fun onCleared() { + compositeDisposable.clear() + super.onCleared() + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchConnectViewModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchConnectViewModel.kt new file mode 100644 index 000000000000..bf6f6e2f3f3f --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchConnectViewModel.kt @@ -0,0 +1,298 @@ +package app.aaps.pump.carelevo.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.pump.ble.ScannedDevice +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.pump.carelevo.ble.CarelevoBleSession +import app.aaps.pump.carelevo.ble.CarelevoBleTransport +import app.aaps.pump.carelevo.command.CmdDiscard +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.common.MutableEventFlow +import app.aaps.pump.carelevo.common.asEventFlow +import app.aaps.pump.carelevo.common.keys.CarelevoBooleanPreferenceKey +import app.aaps.pump.carelevo.common.keys.CarelevoIntPreferenceKey +import app.aaps.pump.carelevo.common.model.Event +import app.aaps.pump.carelevo.common.model.PatchState +import app.aaps.pump.carelevo.common.model.State +import app.aaps.pump.carelevo.common.model.UiState +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoConnectNewPatchUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchForceDiscardUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.model.CarelevoConnectNewPatchRequestModel +import app.aaps.pump.carelevo.presentation.model.CarelevoConnectPrepareEvent +import dagger.hilt.android.lifecycle.HiltViewModel +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull +import kotlin.time.Duration.Companion.milliseconds + +@HiltViewModel +class CarelevoPatchConnectViewModel @Inject constructor( + private val aapsLogger: AAPSLogger, + private val aapsSchedulers: AapsSchedulers, + private val carelevoPatch: CarelevoPatch, + private val commandQueue: CommandQueue, + private val sp: SP, + private val bleSession: CarelevoBleSession, + private val transport: CarelevoBleTransport, + private val connectNewPatchUseCase: CarelevoConnectNewPatchUseCase, + private val patchForceDiscardUseCase: CarelevoPatchForceDiscardUseCase +) : ViewModel() { + + private var _selectedDevice: ScannedDevice? = null + + private var _isScanWorking = false + val isScanWorking get() = _isScanWorking + + private val commandDelay = 300L + + private val _event = MutableEventFlow() + val event = _event.asEventFlow() + + private val _uiState: MutableStateFlow = MutableStateFlow(UiState.Idle) + val uiState = _uiState.asStateFlow() + + private val compositeDisposable = CompositeDisposable() + + private fun setUiState(state: State) { + _uiState.tryEmit(state) + } + + fun triggerEvent(event: Event) { + viewModelScope.launch { + when (event) { + is CarelevoConnectPrepareEvent -> generateEventType(event).run { _event.emit(this) } + } + } + } + + private fun generateEventType(event: Event): Event { + return when (event) { + is CarelevoConnectPrepareEvent.ShowConnectDialog -> event + is CarelevoConnectPrepareEvent.ShowMessageScanFailed -> event + is CarelevoConnectPrepareEvent.ShowMessageScanIsWorking -> event + is CarelevoConnectPrepareEvent.ShowMessageBluetoothNotEnabled -> event + is CarelevoConnectPrepareEvent.ShowMessageSelectedDeviceIseEmpty -> event + is CarelevoConnectPrepareEvent.ConnectComplete -> event + is CarelevoConnectPrepareEvent.ConnectFailed -> event + is CarelevoConnectPrepareEvent.DiscardComplete -> event + is CarelevoConnectPrepareEvent.DiscardFailed -> event + is CarelevoConnectPrepareEvent.ShowMessageNotSetUserSettingInfo -> event + else -> CarelevoConnectPrepareEvent.NoAction + } + } + + /** + * Discovery scan over the transport. `scanAddress = null` puts `CarelevoBleTransportImpl`'s scanner + * in service-UUID discovery mode; results are collected for a short window and the strongest RSSI + * patch is selected. + */ + fun startScan() { + if (!carelevoPatch.isBluetoothEnabled()) { + triggerEvent(CarelevoConnectPrepareEvent.ShowMessageBluetoothNotEnabled) + return + } + if (isScanWorking) { + triggerEvent(CarelevoConnectPrepareEvent.ShowMessageScanIsWorking) + return + } + + setUiState(UiState.Loading) + _isScanWorking = true + viewModelScope.launch(Dispatchers.IO) { + val found = try { + transport.scanAddress = null + val devicesByAddress = linkedMapOf() + withTimeoutOrNull(DISCOVERY_COLLECTION_MS.milliseconds) { + transport.scanner.scannedDevices + // Subscribe BEFORE starting the scan so an immediate advertisement cannot be missed. + .onSubscription { transport.scanner.startScan() } + .collect { scanned -> + val current = devicesByAddress[scanned.address] + if (scanned.rssi >= MIN_SCAN_RSSI && (current == null || scanned.rssi > current.rssi)) { + devicesByAddress[scanned.address] = scanned + } + } + } + devicesByAddress.values.maxByOrNull { it.rssi } + } finally { + transport.scanner.stopScan() + _isScanWorking = false + } + aapsLogger.info(LTag.PUMPCOMM, "newBle.scan found=${found?.name} address=${found?.address}") + _selectedDevice = found + setUiState(UiState.Idle) + if (found != null) { + triggerEvent(CarelevoConnectPrepareEvent.ShowConnectDialog) + } else { + triggerEvent(CarelevoConnectPrepareEvent.ShowMessageScanFailed) + } + } + } + + fun startPatchDiscardProcess() { + when (carelevoPatch.patchState.value?.getOrNull()) { + is PatchState.NotConnectedNotBooting, null -> { + triggerEvent(CarelevoConnectPrepareEvent.DiscardComplete) + } + + else -> { + // Route the BLE stop through the queue (reconnect-before-execute); if the patch can't + // be reached at all, fall back to the DB-only force-discard. + setUiState(UiState.Loading) + viewModelScope.launch { + val result = commandQueue.customCommand(CmdDiscard()) + if (result.success) { + // unBond + releasePatch run inside CmdDiscard on the queue thread + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectPrepareEvent.DiscardComplete) + } else { + aapsLogger.error(LTag.PUMPCOMM, "discard failed, falling back to force-discard") + startPatchForceDiscard() + } + } + } + } + } + + private fun startPatchForceDiscard() { + setUiState(UiState.Loading) + compositeDisposable += patchForceDiscardUseCase.execute() + .timeout(3000L, TimeUnit.MILLISECONDS) + .observeOn(aapsSchedulers.io) + .subscribeOn(aapsSchedulers.io) + .doOnError { + aapsLogger.debug(LTag.PUMPCOMM, "doOnError called : $it") + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectPrepareEvent.DiscardFailed) + }.subscribe { response -> + when (response) { + is ResponseResult.Success -> { + aapsLogger.debug(LTag.PUMPCOMM, "response success") + carelevoPatch.discardTeardown() + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectPrepareEvent.DiscardComplete) + } + + is ResponseResult.Error -> { + aapsLogger.debug(LTag.PUMPCOMM, "response error : ${response.e}") + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectPrepareEvent.DiscardFailed) + } + + else -> { + aapsLogger.debug(LTag.PUMPCOMM, "[CarelevoConnectPrepareViewMode;::startPatchForceDiscard] response failed") + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectPrepareEvent.DiscardFailed) + } + } + } + } + + /** + * Pair the scanned patch: clear any stale bond → one + * [CarelevoBleSession.runPairing] session (connect → bond → MAC/auth/set-time→patch-info/alarm/ + * threshold) → persist via the use case's `persistNewPatch`. No btState observer needed — the session + * owns its connect handshake and either returns or throws. After success the CommandQueue (activation + * customCommands) takes over. + */ + fun startConnect(inputInsulin: Int) { + aapsLogger.debug(LTag.PUMPCOMM, "startConnect called") + if (!carelevoPatch.isBluetoothEnabled()) { + triggerEvent(CarelevoConnectPrepareEvent.ShowMessageBluetoothNotEnabled) + return + } + val device = _selectedDevice + if (device == null) { + triggerEvent(CarelevoConnectPrepareEvent.ShowMessageSelectedDeviceIseEmpty) + return + } + val userSettingInfo = carelevoPatch.userSettingInfo.value?.getOrNull() + if (userSettingInfo == null) { + triggerEvent(CarelevoConnectPrepareEvent.ShowMessageNotSetUserSettingInfo) + return + } + val request = CarelevoConnectNewPatchRequestModel( + volume = inputInsulin, + expiry = sp.getInt(CarelevoIntPreferenceKey.CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS.key, 116), + remains = userSettingInfo.lowInsulinNoticeAmount!!, + maxBasalSpeed = userSettingInfo.maxBasalSpeed!!, + maxVolume = userSettingInfo.maxBolusDose!!, + isBuzzOn = sp.getBoolean(CarelevoBooleanPreferenceKey.CARELEVO_BUZZER_REMINDER.key, false) + ) + + setUiState(UiState.Loading) + viewModelScope.launch(Dispatchers.IO) { + val outcome = runCatching { + transport.adapter.removeBond(device.address) + delay(commandDelay.milliseconds) + val pairing = bleSession.runPairing( + device.address, + CarelevoBleSession.PairingSpec( + volume = request.volume, + remains = request.remains, + expiry = request.expiry, + maxBasalSpeed = request.maxBasalSpeed, + maxBolusDose = request.maxVolume, + buzzUse = request.isBuzzOn + ) + ) + check( + connectNewPatchUseCase.persistNewPatch( + address = pairing.address, + serialNumber = pairing.serialNumber, + firmwareVersion = pairing.firmwareVersion, + modelName = pairing.modelName, + request = request + ) + ) { "persist new patch failed" } + pairing + } + setUiState(UiState.Idle) + outcome.fold( + onSuccess = { pairing -> + aapsLogger.info(LTag.PUMPCOMM, "newBle.pairing OK serial=${pairing.serialNumber} address=${pairing.address}") + triggerEvent(CarelevoConnectPrepareEvent.ConnectComplete) + }, + onFailure = { e -> + if (e is CancellationException) throw e + aapsLogger.error(LTag.PUMPCOMM, "newBle.pairing FAILED", e) + triggerEvent(CarelevoConnectPrepareEvent.ConnectFailed) + } + ) + } + } + + override fun onCleared() { + aapsLogger.debug(LTag.PUMPCOMM, "onCleared") + compositeDisposable.clear() + } + + fun resetForEnterStep() { + _selectedDevice = null + _isScanWorking = false + transport.scanner.stopScan() + setUiState(UiState.Idle) + } + + private companion object { + + private const val DISCOVERY_COLLECTION_MS = 5_000L + private const val MIN_SCAN_RSSI = -45 + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchConnectionFlowViewModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchConnectionFlowViewModel.kt new file mode 100644 index 000000000000..745462a8f575 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchConnectionFlowViewModel.kt @@ -0,0 +1,402 @@ +package app.aaps.pump.carelevo.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.aaps.core.data.model.ICfg +import app.aaps.core.data.model.TE +import app.aaps.core.data.time.T +import app.aaps.core.data.ue.Action +import app.aaps.core.data.ue.Sources +import app.aaps.core.data.ue.ValueWithUnit +import app.aaps.core.interfaces.db.PersistenceLayer +import app.aaps.core.interfaces.insulin.InsulinManager +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.profile.ProfileFunction +import app.aaps.core.interfaces.profile.ProfileRepository +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.keys.BooleanKey +import app.aaps.core.keys.IntKey +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.core.ui.compose.pump.ProfileGateStepHost +import app.aaps.core.ui.compose.siteRotation.BodyType +import app.aaps.core.ui.compose.siteRotation.SiteLocationStepHost +import app.aaps.pump.carelevo.command.CmdDiscard +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.common.MutableEventFlow +import app.aaps.pump.carelevo.common.asEventFlow +import app.aaps.pump.carelevo.common.model.Event +import app.aaps.pump.carelevo.common.model.PatchState +import app.aaps.pump.carelevo.common.model.State +import app.aaps.pump.carelevo.common.model.UiState +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchForceDiscardUseCase +import app.aaps.pump.carelevo.presentation.model.CarelevoConnectEvent +import app.aaps.pump.carelevo.presentation.type.CarelevoPatchStep +import app.aaps.pump.carelevo.presentation.type.CarelevoScreenType +import dagger.hilt.android.lifecycle.HiltViewModel +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +@HiltViewModel +class CarelevoPatchConnectionFlowViewModel @Inject constructor( + private val aapsLogger: AAPSLogger, + private val aapsSchedulers: AapsSchedulers, + private val carelevoPatch: CarelevoPatch, + private val commandQueue: CommandQueue, + private val patchForceDiscardUseCase: CarelevoPatchForceDiscardUseCase, + private val preferences: Preferences, + private val profileFunction: ProfileFunction, + private val profileRepository: ProfileRepository, + private val insulinManager: InsulinManager, + private val persistenceLayer: PersistenceLayer +) : ViewModel(), ProfileGateStepHost, SiteLocationStepHost { + + private val _page: MutableStateFlow = MutableStateFlow(CarelevoPatchStep.PATCH_START) + val page = _page.asStateFlow() + + /** Steps actually shown for this run — PROFILE_GATE / SITE_LOCATION are added only when needed. */ + private var workflowSteps: List = listOf( + CarelevoPatchStep.PATCH_START, + CarelevoPatchStep.PATCH_CONNECT, + CarelevoPatchStep.SAFETY_CHECK, + CarelevoPatchStep.PATCH_ATTACH, + CarelevoPatchStep.NEEDLE_INSERTION + ) + + private val _totalSteps = MutableStateFlow(workflowSteps.size) + val totalSteps = _totalSteps.asStateFlow() + + private val _currentStepIndex = MutableStateFlow(0) + val currentStepIndex = _currentStepIndex.asStateFlow() + + // ProfileGateStepHost state + private val _availableProfiles = MutableStateFlow>(emptyList()) + override val availableProfiles = _availableProfiles.asStateFlow() + private val _selectedProfile = MutableStateFlow(null) + override val selectedProfile = _selectedProfile.asStateFlow() + + // SiteLocationStepHost state + private val _siteLocation = MutableStateFlow(TE.Location.NONE) + override val siteLocation = _siteLocation.asStateFlow() + private val _siteArrow = MutableStateFlow(TE.Arrow.NONE) + override val siteArrow = _siteArrow.asStateFlow() + private val _siteRotationEntries = MutableStateFlow>(emptyList()) + + // Insulin selection state (for SELECT_INSULIN step) + private val _availableInsulins = MutableStateFlow>(emptyList()) + val availableInsulins = _availableInsulins.asStateFlow() + private val _selectedInsulin = MutableStateFlow(null) + val selectedInsulin = _selectedInsulin.asStateFlow() + private val _activeInsulinLabel = MutableStateFlow(null) + val activeInsulinLabel = _activeInsulinLabel.asStateFlow() + + val concentrationEnabled: Boolean + get() = preferences.get(BooleanKey.GeneralInsulinConcentration) + + /** The insulin-selection step is only shown when more than one insulin is configured. */ + val showInsulinStep: Boolean + get() = _availableInsulins.value.size > 1 + + private var _isCreated = false + val isCreated get() = _isCreated + + private val _event = MutableEventFlow() + val event = _event.asEventFlow() + + private val _uiState: MutableStateFlow = MutableStateFlow(UiState.Idle) + val uiState = _uiState.asStateFlow() + + private var _inputInsulin = 300 + val inputInsulin get() = _inputInsulin + + private val compositeDisposable = CompositeDisposable() + + fun setIsCreated(isCreated: Boolean) { + _isCreated = isCreated + } + + fun setPage(page: CarelevoPatchStep) { + _page.tryEmit(page) + _currentStepIndex.value = workflowSteps.indexOf(page).coerceAtLeast(0) + } + + /** Build the step list for this run and jump to the first (or resumed) step. */ + suspend fun initWorkflow(screenType: CarelevoScreenType) { + val needsProfileGate = profileFunction.getRequestedProfile() == null + val siteRotationEnabled = preferences.get(BooleanKey.SiteRotationManagePump) + loadInsulins() + val insulinSelection = showInsulinStep + _siteLocation.value = TE.Location.NONE + _siteArrow.value = TE.Arrow.NONE + carelevoPatch.setSitePlacement(TE.Location.NONE, TE.Arrow.NONE) + workflowSteps = buildList { + if (needsProfileGate) add(CarelevoPatchStep.PROFILE_GATE) + if (insulinSelection) add(CarelevoPatchStep.SELECT_INSULIN) + add(CarelevoPatchStep.PATCH_START) + add(CarelevoPatchStep.SET_AMOUNT) + add(CarelevoPatchStep.PATCH_CONNECT) + add(CarelevoPatchStep.SAFETY_CHECK) + if (siteRotationEnabled) add(CarelevoPatchStep.SITE_LOCATION) + add(CarelevoPatchStep.PATCH_ATTACH) + add(CarelevoPatchStep.NEEDLE_INSERTION) + } + _totalSteps.value = workflowSteps.size + if (needsProfileGate) loadAvailableProfiles() + if (siteRotationEnabled) loadSiteRotationEntries() + val initial = when (screenType) { + CarelevoScreenType.SAFETY_CHECK -> CarelevoPatchStep.SAFETY_CHECK + CarelevoScreenType.NEEDLE_INSERTION -> CarelevoPatchStep.PATCH_ATTACH + else -> workflowSteps.first() + } + setPage(initial) + } + + /** After the safety check, route through the site-location step when it is enabled. */ + fun advanceFromSafetyCheck() { + setPage( + if (workflowSteps.contains(CarelevoPatchStep.SITE_LOCATION)) CarelevoPatchStep.SITE_LOCATION + else CarelevoPatchStep.PATCH_ATTACH + ) + } + + /** Advance to whatever step actually follows [current] in this run's step list. */ + private fun goToNextStep(current: CarelevoPatchStep) { + // A step that is not part of this run has index -1; without the guard the +1 would + // resolve to the first step and silently rewind the wizard. + val currentIndex = workflowSteps.indexOf(current) + if (currentIndex < 0) return + val next = workflowSteps.getOrNull(currentIndex + 1) ?: return + setPage(next) + } + + fun setInputInsulin(insulin: Int) { + _inputInsulin = insulin + } + + /** Commit the chosen fill amount from the SET_AMOUNT step and continue. */ + fun confirmAmount(amount: Int) { + setInputInsulin(amount) + goToNextStep(CarelevoPatchStep.SET_AMOUNT) + } + + fun triggerEvent(event: Event) { + viewModelScope.launch { + when (event) { + is CarelevoConnectEvent -> generateEventType(event).run { _event.emit(this) } + } + } + } + + private fun generateEventType(event: Event): Event { + return when (event) { + is CarelevoConnectEvent.DiscardComplete -> event + is CarelevoConnectEvent.DiscardFailed -> event + is CarelevoConnectEvent.ExitFlow -> event + else -> CarelevoConnectEvent.NoAction + } + } + + private fun setUiState(state: State) { + viewModelScope.launch { + _uiState.tryEmit(state) + } + } + + fun startPatchDiscardProcess() { + when (carelevoPatch.patchState.value?.getOrNull()) { + is PatchState.NotConnectedNotBooting, null -> { + triggerEvent(CarelevoConnectEvent.DiscardComplete) + } + + else -> { + // Route the BLE stop through the queue (reconnect-before-execute); if the patch can't + // be reached at all, fall back to the DB-only force-discard. + setUiState(UiState.Loading) + viewModelScope.launch { + val result = commandQueue.customCommand(CmdDiscard()) + if (result.success) { + // unBond + releasePatch run inside CmdDiscard on the queue thread + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectEvent.DiscardComplete) + } else { + aapsLogger.error(LTag.PUMPCOMM, "discard failed, falling back to force-discard") + startPatchForceDiscard() + } + } + } + } + } + + private fun startPatchForceDiscard() { + setUiState(UiState.Loading) + compositeDisposable += patchForceDiscardUseCase.execute() + .timeout(3000L, TimeUnit.MILLISECONDS) + .observeOn(aapsSchedulers.io) + .doOnError { + aapsLogger.debug(LTag.PUMPCOMM, "doOnError called : $it") + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectEvent.DiscardFailed) + } + .subscribeOn(aapsSchedulers.io) + .subscribe { response -> + when (response) { + is ResponseResult.Success -> { + aapsLogger.debug(LTag.PUMPCOMM, "response success") + carelevoPatch.discardTeardown() + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectEvent.DiscardComplete) + } + + is ResponseResult.Error -> { + aapsLogger.debug(LTag.PUMPCOMM, "response error : ${response.e}") + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectEvent.DiscardFailed) + } + + else -> { + aapsLogger.debug(LTag.PUMPCOMM, "response failed") + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectEvent.DiscardFailed) + } + } + } + } + + // region ProfileGateStepHost + + private suspend fun loadAvailableProfiles() { + val names = profileRepository.profiles.value.map { it.name } + _availableProfiles.value = names + if (_selectedProfile.value !in names) { + val activeName = profileFunction.getOriginalProfileName() + _selectedProfile.value = activeName.takeIf { it in names } ?: names.firstOrNull() + } + } + + override fun selectProfile(name: String) { + _selectedProfile.value = name + } + + override fun activateSelectedProfile() { + val name = _selectedProfile.value ?: return + val store = profileRepository.profile.value ?: return + val iCfg = _selectedInsulin.value ?: insulinManager.insulins.firstOrNull() ?: return + viewModelScope.launch { + val result = profileFunction.createProfileSwitch( + profileStore = store, + profileName = name, + durationInMinutes = 0, + percentage = 100, + timeShiftInHours = 0, + timestamp = System.currentTimeMillis(), + action = Action.PROFILE_SWITCH, + source = Sources.Carelevo, + note = null, + listValues = listOf(ValueWithUnit.SimpleString(name)), + iCfg = iCfg + ) + if (result == null) { + aapsLogger.error(LTag.PUMP, "ProfileGate: createProfileSwitch failed for $name") + } else { + goToNextStep(CarelevoPatchStep.PROFILE_GATE) + } + } + } + + override fun cancelGate() { + exitWizard() + } + + fun exitWizard() { + triggerEvent(CarelevoConnectEvent.ExitFlow) + } + + // endregion + + // region SiteLocationStepHost + + override fun updateSiteLocation(location: TE.Location) { + _siteLocation.value = location + carelevoPatch.setSitePlacement(location, _siteArrow.value) + } + + override fun updateSiteArrow(arrow: TE.Arrow) { + _siteArrow.value = arrow + carelevoPatch.setSitePlacement(_siteLocation.value, arrow) + } + + override fun completeSiteLocation() { + carelevoPatch.setSitePlacement(_siteLocation.value, _siteArrow.value) + setPage(CarelevoPatchStep.PATCH_ATTACH) + } + + override fun skipSiteLocation() { + _siteLocation.value = TE.Location.NONE + _siteArrow.value = TE.Arrow.NONE + carelevoPatch.setSitePlacement(TE.Location.NONE, TE.Arrow.NONE) + setPage(CarelevoPatchStep.PATCH_ATTACH) + } + + override fun bodyType(): BodyType = + BodyType.fromPref(preferences.get(IntKey.SiteRotationUserProfile)) + + override fun siteRotationEntries(): List = _siteRotationEntries.value + + private fun loadSiteRotationEntries() { + viewModelScope.launch { + _siteRotationEntries.value = persistenceLayer.getTherapyEventDataFromTime( + System.currentTimeMillis() - T.days(45).msecs(), false + ).filter { it.type == TE.Type.CANNULA_CHANGE || it.type == TE.Type.SENSOR_CHANGE } + } + } + + // endregion + + // region Insulin selection (SELECT_INSULIN step) + + fun selectInsulin(iCfg: ICfg) { + _selectedInsulin.value = iCfg + } + + private suspend fun loadInsulins() { + if (_availableInsulins.value.isNotEmpty()) return + val insulins = insulinManager.insulins.map { it.deepClone() } + val activeLabel = profileFunction.getProfile()?.iCfg?.insulinLabel + _availableInsulins.value = insulins + _selectedInsulin.value = insulins.find { it.insulinLabel == activeLabel } ?: insulins.firstOrNull() + _activeInsulinLabel.value = activeLabel + } + + /** Apply the chosen insulin (if different from the active one) and continue to the fill step. */ + fun advanceFromInsulin() { + val selected = _selectedInsulin.value + if (selected == null) { + goToNextStep(CarelevoPatchStep.SELECT_INSULIN) + return + } + // Commit the insulin/profile switch BEFORE advancing, so later steps never race a + // still-in-flight profile change. + viewModelScope.launch { + val currentLabel = profileFunction.getProfile()?.iCfg?.insulinLabel + if (selected.insulinLabel != currentLabel) { + profileFunction.createProfileSwitchWithNewInsulin(selected, Sources.Carelevo) + } + goToNextStep(CarelevoPatchStep.SELECT_INSULIN) + } + } + + // endregion + + override fun onCleared() { + super.onCleared() + compositeDisposable.clear() + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchNeedleInsertionViewModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchNeedleInsertionViewModel.kt new file mode 100644 index 000000000000..72d956afbd04 --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchNeedleInsertionViewModel.kt @@ -0,0 +1,367 @@ +package app.aaps.pump.carelevo.presentation.viewmodel + +import android.os.SystemClock +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.aaps.core.data.model.TE +import app.aaps.core.data.pump.defs.PumpType +import app.aaps.core.interfaces.db.PersistenceLayer +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.pump.carelevo.command.CmdDiscard +import app.aaps.pump.carelevo.command.CmdNeedleCheck +import app.aaps.pump.carelevo.command.CmdSetBasal +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.common.MutableEventFlow +import app.aaps.pump.carelevo.common.asEventFlow +import app.aaps.pump.carelevo.common.model.Event +import app.aaps.pump.carelevo.common.model.PatchState +import app.aaps.pump.carelevo.common.model.State +import app.aaps.pump.carelevo.common.model.UiState +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType +import app.aaps.pump.carelevo.domain.usecase.alarm.CarelevoAlarmInfoUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchForceDiscardUseCase +import app.aaps.pump.carelevo.presentation.model.CarelevoConnectNeedleEvent +import dagger.hilt.android.lifecycle.HiltViewModel +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.time.LocalDateTime +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +@HiltViewModel +class CarelevoPatchNeedleInsertionViewModel @Inject constructor( + private val aapsLogger: AAPSLogger, + private val pumpSync: PumpSync, + private val persistenceLayer: PersistenceLayer, + private val aapsSchedulers: AapsSchedulers, + private val carelevoPatch: CarelevoPatch, + private val commandQueue: CommandQueue, + private val patchForceDiscardUseCase: CarelevoPatchForceDiscardUseCase, + private val carelevoAlarmInfoUseCase: CarelevoAlarmInfoUseCase +) : ViewModel() { + + companion object { + + private const val INSERT_RETRY_DELAY_MS = 150L + private const val NEEDLE_TO_BASAL_DELAY_MS = 10_000L + } + + private val _isNeedleInsert: MutableStateFlow = MutableStateFlow(false) + val isNeedleInsert = _isNeedleInsert.asStateFlow() + + private val _event = MutableEventFlow() + val event = _event.asEventFlow() + + private val _uiState: MutableStateFlow = MutableStateFlow(UiState.Idle) + val uiState = _uiState.asStateFlow() + + private var _isCreated = false + val isCreated get() = _isCreated + private var needleInsertedAtMs: Long? = null + private var delayedStartBasalJob: Job? = null + + private val compositeDisposable = CompositeDisposable() + + fun setIsCreated(isCreated: Boolean) { + _isCreated = isCreated + } + + fun triggerEvent(event: Event) { + viewModelScope.launch { + when (event) { + is CarelevoConnectNeedleEvent -> generateEventType(event).run { _event.emit(this) } + } + } + } + + private fun generateEventType(event: Event): Event { + return when (event) { + is CarelevoConnectNeedleEvent.ShowMessageBluetoothNotEnabled -> event + is CarelevoConnectNeedleEvent.ShowMessageCarelevoIsNotConnected -> event + is CarelevoConnectNeedleEvent.ShowMessageProfileNotSet -> event + is CarelevoConnectNeedleEvent.CheckNeedleComplete -> event + is CarelevoConnectNeedleEvent.CheckNeedleFailed -> event + is CarelevoConnectNeedleEvent.CheckNeedleError -> event + is CarelevoConnectNeedleEvent.DiscardComplete -> event + is CarelevoConnectNeedleEvent.DiscardFailed -> event + is CarelevoConnectNeedleEvent.SetBasalComplete -> event + is CarelevoConnectNeedleEvent.SetBasalFailed -> event + else -> CarelevoConnectNeedleEvent.NoAction + } + } + + private fun setUiState(state: State) { + viewModelScope.launch { + _uiState.tryEmit(state) + } + } + + fun observePatchInfo() { + compositeDisposable += carelevoPatch.patchInfo + .observeOn(aapsSchedulers.main) + .subscribeOn(aapsSchedulers.io) + .subscribe { + val patchInfo = it?.getOrNull() ?: return@subscribe + aapsLogger.debug(LTag.PUMPCOMM, "observePatchInfo patchInfo=$patchInfo") + val isNeedleInserted = patchInfo.checkNeedle ?: false + _isNeedleInsert.tryEmit(isNeedleInserted) + if (isNeedleInserted) { + if (needleInsertedAtMs == null) needleInsertedAtMs = System.currentTimeMillis() + } else { + needleInsertedAtMs = null + delayedStartBasalJob?.cancel() + } + + val failedCount = patchInfo.needleFailedCount ?: 0 + if (failedCount >= 3) { + recordNeedleInsertFailAlarm() + triggerEvent(CarelevoConnectNeedleEvent.CheckNeedleFailed(failedCount)) + } + } + } + + fun startCheckNeedle() { + if (!carelevoPatch.isBluetoothEnabled()) { + triggerEvent(CarelevoConnectNeedleEvent.ShowMessageBluetoothNotEnabled) + return + } + + setUiState(UiState.Loading) + // Routed through the CommandQueue: it connects (and reconnects if the link dropped) before + // running the check on the queue worker thread, so a mid-activation drop is handled. + viewModelScope.launch { + val result = commandQueue.customCommand(CmdNeedleCheck()) + setUiState(UiState.Idle) + if (result.success) { + triggerEvent(CarelevoConnectNeedleEvent.CheckNeedleComplete(true)) + } else { + // A failed check leaves the failure count on the patch; a communication failure + // (queue could not reach the patch) leaves no count → surface a generic error. + val failedCount = needleFailCount() + if (failedCount != null) { + triggerEvent(CarelevoConnectNeedleEvent.CheckNeedleFailed(failedCount)) + } else { + triggerEvent(CarelevoConnectNeedleEvent.CheckNeedleError) + } + } + } + } + + fun startSetBasal() { + val insertedAt = needleInsertedAtMs + if (insertedAt != null) { + val elapsed = System.currentTimeMillis() - insertedAt + val remain = NEEDLE_TO_BASAL_DELAY_MS - elapsed + if (remain > 0) { + setUiState(UiState.Loading) + aapsLogger.debug( + LTag.PUMPCOMM, + "delayed ${remain}ms (elapsed=${elapsed}ms after needle insert)" + ) + delayedStartBasalJob?.cancel() + delayedStartBasalJob = viewModelScope.launch { + delay(remain) + startSetBasal() + } + return + } + } + + if (!carelevoPatch.isBluetoothEnabled()) { + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectNeedleEvent.ShowMessageBluetoothNotEnabled) + return + } + if (carelevoPatch.profile.value?.getOrNull() == null) { + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectNeedleEvent.ShowMessageProfileNotSet) + return + } + + setUiState(UiState.Loading) + // Routed through the CommandQueue (connect/reconnect-before-execute). The executor reads the + // profile off the patch and programs the basal; the post-success bookkeeping (pump sync + + // therapy events) stays here on the VM. + viewModelScope.launch { + val result = commandQueue.customCommand(CmdSetBasal()) + if (result.success) { + aapsLogger.debug(LTag.PUMPCOMM, "set basal success") + val serial = carelevoPatch.patchInfo.value?.getOrNull()?.manufactureNumber ?: "" + pumpSync.connectNewPump(true) + delay(1000) + insertCannulaChangeWithSite(serial, System.currentTimeMillis()) + insertTherapyEventWithSingleRetry(TE.Type.INSULIN_CHANGE, serial) + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectNeedleEvent.SetBasalComplete) + } else { + aapsLogger.debug(LTag.PUMPCOMM, "set basal failed") + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectNeedleEvent.SetBasalFailed) + } + } + } + + private fun insertTherapyEventWithSingleRetry(type: TE.Type, serial: String) { + viewModelScope.launch { + var inserted = pumpSync.insertTherapyEventIfNewWithTimestamp( + timestamp = System.currentTimeMillis(), + type = type, + pumpType = PumpType.CAREMEDI_CARELEVO, + pumpSerial = serial + ) + aapsLogger.debug(LTag.PUMPCOMM, "$type insert result=$inserted serial=$serial") + if (!inserted) { + SystemClock.sleep(INSERT_RETRY_DELAY_MS) + inserted = pumpSync.insertTherapyEventIfNewWithTimestamp( + timestamp = System.currentTimeMillis(), + type = type, + pumpType = PumpType.CAREMEDI_CARELEVO, + pumpSerial = serial + ) + aapsLogger.debug(LTag.PUMPCOMM, "$type recovery insert result=$inserted serial=$serial") + } + } + } + + /** + * Insert the CANNULA_CHANGE event and, if a site location was chosen in the wizard's + * site-location step, patch it onto that event (matches Medtrum/Equil behaviour). + */ + private fun insertCannulaChangeWithSite(serial: String, timestamp: Long) { + viewModelScope.launch { + var inserted = pumpSync.insertTherapyEventIfNewWithTimestamp( + timestamp = timestamp, + type = TE.Type.CANNULA_CHANGE, + pumpType = PumpType.CAREMEDI_CARELEVO, + pumpSerial = serial + ) + aapsLogger.debug(LTag.PUMPCOMM, "CANNULA_CHANGE insert result=$inserted serial=$serial") + if (!inserted) { + SystemClock.sleep(INSERT_RETRY_DELAY_MS) + inserted = pumpSync.insertTherapyEventIfNewWithTimestamp( + timestamp = timestamp, + type = TE.Type.CANNULA_CHANGE, + pumpType = PumpType.CAREMEDI_CARELEVO, + pumpSerial = serial + ) + aapsLogger.debug(LTag.PUMPCOMM, "CANNULA_CHANGE recovery insert result=$inserted serial=$serial") + } + saveSiteLocationToTherapyEvent(timestamp) + } + } + + /** Patch the chosen site location/arrow onto the CANNULA_CHANGE therapy event just recorded. */ + private suspend fun saveSiteLocationToTherapyEvent(timestamp: Long) { + val location = carelevoPatch.sitePlacementLocation.takeIf { it != TE.Location.NONE } + val arrow = carelevoPatch.sitePlacementArrow.takeIf { it != TE.Arrow.NONE } + if (location == null && arrow == null) return + try { + persistenceLayer.getTherapyEventDataFromToTime(timestamp, timestamp) + .firstOrNull { it.type == TE.Type.CANNULA_CHANGE } + ?.let { te -> + persistenceLayer.insertOrUpdateTherapyEvent(te.copy(location = location, arrow = arrow)) + } + } catch (_: Exception) { + // site location is optional; ignore failures + } + } + + fun startDiscardProcess() { + when (carelevoPatch.patchState.value?.getOrNull()) { + is PatchState.NotConnectedNotBooting, null -> { + triggerEvent(CarelevoConnectNeedleEvent.DiscardComplete) + } + + else -> { + // Route the BLE stop through the queue (reconnect-before-execute); if the patch can't + // be reached at all, fall back to the DB-only force-discard. + setUiState(UiState.Loading) + viewModelScope.launch { + val result = commandQueue.customCommand(CmdDiscard()) + if (result.success) { + // unBond + releasePatch run inside CmdDiscard on the queue thread + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectNeedleEvent.DiscardComplete) + } else { + aapsLogger.error(LTag.PUMPCOMM, "discard failed, falling back to force-discard") + startForceDiscard() + } + } + } + } + } + + private fun startForceDiscard() { + setUiState(UiState.Loading) + compositeDisposable += patchForceDiscardUseCase.execute() + .timeout(3000L, TimeUnit.MILLISECONDS) + .observeOn(aapsSchedulers.io) + .subscribeOn(aapsSchedulers.io) + .subscribe({ response -> + when (response) { + is ResponseResult.Success -> { + aapsLogger.debug(LTag.PUMPCOMM, "response success") + carelevoPatch.discardTeardown() + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectNeedleEvent.DiscardComplete) + } + + is ResponseResult.Error -> { + aapsLogger.debug(LTag.PUMPCOMM, "response error : ${response.e}") + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectNeedleEvent.DiscardFailed) + } + + else -> { + aapsLogger.debug(LTag.PUMPCOMM, "response failed") + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectNeedleEvent.DiscardFailed) + } + } + }, { e -> + aapsLogger.debug(LTag.PUMPCOMM, "force discard failed : $e") + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectNeedleEvent.DiscardFailed) + }) + } + + private fun recordNeedleInsertFailAlarm() { + val info = CarelevoAlarmInfo( + alarmId = System.currentTimeMillis().toString(), + alarmType = AlarmType.WARNING, + cause = AlarmCause.ALARM_WARNING_NEEDLE_INSERTION_ERROR, + createdAt = LocalDateTime.now().toString(), + updatedAt = LocalDateTime.now().toString(), + isAcknowledged = false, + + ) + compositeDisposable += carelevoAlarmInfoUseCase.upsertAlarm(info) + .subscribeOn(aapsSchedulers.io) + .observeOn(aapsSchedulers.io) + .subscribe( + { aapsLogger.debug(LTag.PUMPCOMM, "recordNeedleInsertFailAlarm.upsertComplete") }, + { e -> aapsLogger.error(LTag.PUMPCOMM, "recordNeedleInsertFailAlarm.upsertError error=$e") } + ) + } + + fun needleFailCount() = carelevoPatch.patchInfo.value?.getOrNull()?.needleFailedCount + + override fun onCleared() { + delayedStartBasalJob?.cancel() + compositeDisposable.clear() + super.onCleared() + } +} diff --git a/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchSafetyCheckViewModel.kt b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchSafetyCheckViewModel.kt new file mode 100644 index 000000000000..d8fa3118308c --- /dev/null +++ b/pump/carelevo/src/main/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchSafetyCheckViewModel.kt @@ -0,0 +1,250 @@ +package app.aaps.pump.carelevo.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.pump.carelevo.command.CarelevoActivationExecutor +import app.aaps.pump.carelevo.command.CmdAdditionalPriming +import app.aaps.pump.carelevo.command.CmdDiscard +import app.aaps.pump.carelevo.command.CmdSafetyCheck +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.common.MutableEventFlow +import app.aaps.pump.carelevo.common.asEventFlow +import app.aaps.pump.carelevo.common.model.Event +import app.aaps.pump.carelevo.common.model.PatchState +import app.aaps.pump.carelevo.common.model.State +import app.aaps.pump.carelevo.common.model.UiState +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.type.SafetyProgress +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchForceDiscardUseCase +import app.aaps.pump.carelevo.presentation.model.CarelevoConnectSafetyCheckEvent +import dagger.hilt.android.lifecycle.HiltViewModel +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.kotlin.plusAssign +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.jvm.optionals.getOrNull + +@HiltViewModel +class CarelevoPatchSafetyCheckViewModel @Inject constructor( + private val aapsSchedulers: AapsSchedulers, + private val aapsLogger: AAPSLogger, + private val carelevoPatch: CarelevoPatch, + private val commandQueue: CommandQueue, + private val activationExecutor: CarelevoActivationExecutor, + private val patchForceDiscardUseCase: CarelevoPatchForceDiscardUseCase +) : ViewModel() { + + private var _isCreated = false + val isCreated get() = _isCreated + + private val _event = MutableEventFlow() + val event = _event.asEventFlow() + + private val _uiState = MutableStateFlow(UiState.Idle) + val uiState = _uiState.asStateFlow() + + private val _progress = MutableStateFlow(null) + val progress = _progress.asStateFlow() + + private val _remainSec = MutableStateFlow(null) + val remainSec = _remainSec.asStateFlow() + + private val compositeDisposable = CompositeDisposable() + + private var tickerDisposable: Disposable? = null + private var currentTimeoutSec: Long = 0L + private val timeTickerDisposable = CompositeDisposable() + + fun setIsCreated(isCreated: Boolean) { + _isCreated = isCreated + } + + fun triggerEvent(event: Event) { + viewModelScope.launch { + when (event) { + is CarelevoConnectSafetyCheckEvent -> generateEventType(event).run { _event.emit(this) } + } + } + } + + private fun generateEventType(event: Event): Event { + return when (event) { + is CarelevoConnectSafetyCheckEvent.ShowMessageBluetoothNotEnabled -> event + is CarelevoConnectSafetyCheckEvent.ShowMessageCarelevoIsNotConnected -> event + is CarelevoConnectSafetyCheckEvent.SafetyCheckProgress -> event + is CarelevoConnectSafetyCheckEvent.SafetyCheckComplete -> event + is CarelevoConnectSafetyCheckEvent.SafetyCheckFailed -> event + is CarelevoConnectSafetyCheckEvent.DiscardComplete -> event + is CarelevoConnectSafetyCheckEvent.DiscardFailed -> event + else -> CarelevoConnectSafetyCheckEvent.NoAction + } + } + + private fun setUiState(state: State) { + viewModelScope.launch { + _uiState.tryEmit(state) + } + } + + fun startSafetyCheck() { + if (!carelevoPatch.isBluetoothEnabled()) { + triggerEvent(CarelevoConnectSafetyCheckEvent.ShowMessageBluetoothNotEnabled) + return + } + + // Route through the CommandQueue: it reconnects if the link dropped, runs the check on the + // queue thread, and returns a real success/fail result. + triggerEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckProgress) + viewModelScope.launch { + val progressJob = launch { + activationExecutor.safetyProgress.collect { progress -> + if (progress is SafetyProgress.Progress) { + currentTimeoutSec = maxOf(1L, progress.timeoutSec) + _progress.value = 0 + _remainSec.value = currentTimeoutSec + startTicker(currentTimeoutSec) + } + } + } + val result = commandQueue.customCommand(CmdSafetyCheck()) + progressJob.cancel() + stopTicker() + if (result.success) { + aapsLogger.debug(LTag.PUMPCOMM, "safety check success") + _progress.value = 100 + _remainSec.value = 0 + triggerEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckComplete) + } else { + aapsLogger.error(LTag.PUMPCOMM, "safety check failed") + triggerEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckFailed) + } + } + } + + private fun startTicker(sec: Long) { + val timeoutSec = sec - 30 + stopTicker() + + tickerDisposable = Observable.intervalRange(0, timeoutSec + 1, 0, 1, TimeUnit.SECONDS) + .observeOn(aapsSchedulers.main) + .subscribe { tick -> + val percent = ((tick.toDouble() / timeoutSec) * 100.0) + .coerceIn(0.0, 100.0) + .toInt() + + _progress.value = maxOf(_progress.value ?: 0, percent) + _remainSec.value = (timeoutSec - tick).coerceAtLeast(0) + + aapsLogger.debug(LTag.UI, "percent: $percent, remain: ${_remainSec.value}") + } + + tickerDisposable?.let(timeTickerDisposable::add) + } + + private fun stopTicker() { + tickerDisposable?.dispose() + tickerDisposable = null + } + + fun startDiscardProcess() { + when (carelevoPatch.patchState.value?.getOrNull()) { + is PatchState.NotConnectedNotBooting, null -> { + triggerEvent(CarelevoConnectSafetyCheckEvent.DiscardComplete) + } + + else -> { + // Route the BLE stop through the queue (reconnect-before-execute); if the patch can't + // be reached at all, fall back to the DB-only force-discard. + setUiState(UiState.Loading) + viewModelScope.launch { + val result = commandQueue.customCommand(CmdDiscard()) + if (result.success) { + // unBond + releasePatch run inside CmdDiscard on the queue thread + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectSafetyCheckEvent.DiscardComplete) + } else { + aapsLogger.error(LTag.PUMPCOMM, "discard failed, falling back to force-discard") + startForceDiscard() + } + } + } + } + } + + private fun startForceDiscard() { + setUiState(UiState.Loading) + compositeDisposable += patchForceDiscardUseCase.execute() + .timeout(3000L, TimeUnit.MILLISECONDS) + .subscribeOn(aapsSchedulers.io) + .observeOn(aapsSchedulers.main) + .subscribe({ response -> + when (response) { + is ResponseResult.Success -> { + aapsLogger.debug(LTag.PUMPCOMM, "response success") + carelevoPatch.discardTeardown() + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectSafetyCheckEvent.DiscardComplete) + } + + is ResponseResult.Error -> { + aapsLogger.error(LTag.PUMPCOMM, "response error : ${response.e}") + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectSafetyCheckEvent.DiscardFailed) + } + + else -> { + aapsLogger.error(LTag.PUMPCOMM, "response failed") + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectSafetyCheckEvent.DiscardFailed) + } + } + }, { e -> + aapsLogger.error(LTag.PUMPCOMM, "force discard failed : $e") + setUiState(UiState.Idle) + triggerEvent(CarelevoConnectSafetyCheckEvent.DiscardFailed) + }) + } + + fun retryAdditionalPriming() { + if (!carelevoPatch.isBluetoothEnabled()) { + triggerEvent(CarelevoConnectSafetyCheckEvent.ShowMessageBluetoothNotEnabled) + return + } + + setUiState(UiState.Loading) + // Routed through the CommandQueue (connect/reconnect-before-execute). Best-effort re-prime to + // push a droplet out; on failure surface feedback rather than silently going idle. + viewModelScope.launch { + val result = commandQueue.customCommand(CmdAdditionalPriming()) + setUiState(UiState.Idle) + if (!result.success) { + aapsLogger.error(LTag.PUMPCOMM, "additional priming failed") + triggerEvent(CarelevoConnectSafetyCheckEvent.DiscardFailed) + } + } + } + + fun isSafetyCheckPassed() = carelevoPatch.patchInfo.value?.getOrNull()?.checkSafety == true + + // Per-op sessions leave no resting link: "connected" = a session can be attempted (patch paired + BT on). + fun isConnected() = carelevoPatch.getPatchInfoAddress() != null && carelevoPatch.isBluetoothEnabled() + + override fun onCleared() { + compositeDisposable.clear() + } + + fun onSafetyCheckComplete() { + _progress.value = 100 + _remainSec.value = 0 + triggerEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckComplete) + } +} diff --git a/pump/carelevo/src/main/res/drawable/ic_carelevo_128.xml b/pump/carelevo/src/main/res/drawable/ic_carelevo_128.xml new file mode 100644 index 000000000000..47b45c989578 --- /dev/null +++ b/pump/carelevo/src/main/res/drawable/ic_carelevo_128.xml @@ -0,0 +1,210 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pump/carelevo/src/main/res/values-ko/strings.xml b/pump/carelevo/src/main/res/values-ko/strings.xml new file mode 100644 index 000000000000..1e6003c29852 --- /dev/null +++ b/pump/carelevo/src/main/res/values-ko/strings.xml @@ -0,0 +1,284 @@ + + Carelevo + CL + Pump integration for carelevo + + 확인 + + 블루투스 상태 + 시리얼 번호 + 펌웨어 버전 + 시작 시간 + 만료 시간 + 기본 basal 양 + 임시 basal 양 + 남은 인슐린 + 패치 남은 시간 + 총 인슐린 주입량 + + %d시간 %d분 + %d분 + %d시간 + %s U + %.2f U/hr + + 주입을 재개 할까요? + 확인 버튼을 누르면 주입이 재개 됩니다. + + 주입 정지 시간을 설정해 주세요. + 30분 + 1시간 + 1시간 30분 + 2시간 + 2시간 30분 + 3시간 + 3시간 30분 + 4시간 + + %1$d일 %2$02d:%3$02d + %d분 %02d초 남음 + + 인슐린 채우기 가이드 + 사용 종료 + 패치 검색 + 재시도 + 다음 + 안전 점검 + 취소 + 확인 + 재검색 + 바늘 삽입 점검 + + 연결 안됨 + 연결 끊김 + 연결 됨 + + 패치 연결 + 패치 종료 + + 채운 인슐린 양 입력 + 설정 가능 범위 : 50 ~ 300 U + + 1. 패치 블리스터 뒷면의 포장지를 제거합니다. + 2. 알콜솜을 사용하여 인슐린 바이알의 고무캡을 소독합니다. + 3. 인슐린 주입용 주사기의 바늘 보호캡을 당겨 제거합니다. + 4. 주사기의 플런저를 잡아당겨 공기 약 2 mL를 채웁니다. + 5. 공기를 채운 주사기를 인슐린 바이알에 꽂은 후 공기를 바이알 안으로 주입합니다. + 6. 주사기를 꽂은 상태로, 인슐린 바이알과 주사기를 거꾸로 뒤집고 주사기의 압력을 이용해 인슐린 약 2 mL를 채웁니다. + 7. 패치의 인슐린 주입구(①)에 주사기 바늘이 바닥에 닿을 때까지 수직으로 끝까지 삽입하고, 주사기 플런저를 위로 끝까지 당겼다가 그대로 놓습니다. 주사기를 인슐린 주입구에서 제거합니다. + 8. 패치에서 제거한 주사기를 똑바로 세운 후 측면을 가볍게 쳐서 공기를 위쪽으로 모두 모이게 합니다. + 9. 인슐린 바이알에 바늘을 삽입하고, 주사기 안에 공기가 모두 바이알 안으로 들어가게 한 후 패치 사용 기간 동안 사용할 인슐린 양만큼 주사기에 인슐린을 더 채웁니다. + 10. 주사기를 바이알에서 뺀 후, 만약에 공기 방울이 있다면 주사기를 똑바로 세우고 측면을 가볍게 톡톡 쳐서 공기를 위쪽으로 모이게 하여 주사기 안에 있는 공기를 모두 제거합니다. + 11. 주사기의 플런저 가장 위쪽의 눈금을 보고 채우려는 인슐린 용량을 정확히 맞춰줍니다. + 12. 패치의 인슐린 주입구(①)에 주사기 바늘이 바닥에 닿을 때까지 수직으로 끝까지 삽입하고, 플런저를 천천히 밀어 주사기 안의 모든 인슐린을 패치에 주입합니다. + + 패치 시작 + 패치 검색 및 연결 + 안전 점검 + 패치 부착 + 바늘 삽입 + + 패치 부저 알람 + 인슐린 부족 알람 설정 값 + 패치 사용 기간 알림 설정 값 + + + 패치에 인슐린 채우기 + 사용 기간 (최대 168시간)을 고려하여 패치에 인슐린을 채워주세요.\n(50 U - 300 U)인슐린 양 입력 버튼을 눌러 채운 인슐린 양을 입력해 주세요. + + STEP 1. + STEP 2. + STEP 3. + + 바늘 보호캡 제거하기 + 패치를 포장재에서 꺼낸 후 바늘 보호캡을 제거해 주세요. + 전원 켜기 및 연결 + 전원 버튼을 1초 이상 길게 눌러 부팅음이 확인 되면 아래 패치 검색 버튼을 눌러주세요. + + 안전점검 + 안전점검 완료 + 패치가 정상적으로 연결되었습니다.\n[안전점검]버튼을 눌러주세요. + 안전점검을 진행중입니다. + 안전점검을 완료했습니다.\n다음 버튼을 눌러주세요. + 바늘 끝에 인슐린 방울이 맺혔는지 확인해주세요. + 인슐린 방울이 맺히지 않았다면 [재시도] 버튼을 눌러 주세요. + + 부착 부위 소독 + 알콜솜으로 깨끗이 소독해 주세요. + 패치 보호 테이프 제거 + 패치의 보호 테이프를 제거해 주세요. + 패치 부착 + 패치에 표시된 점이 위로 향하게 피부에 부착해 주세요. + 피부에 부착한 후, 준비 완료 버튼을 눌러주세요. + + 안전캡 제거 + 안전캡을 화살표 방향으로 90도 돌린 후 안전캡을 들어 제거해 주세요. + 바늘 삽입 + 어플리케이터의 푸시 버튼을 끝까지 눌러 바늘을 삽입해 주세요.\n정상적으로 바늘이 삽입되면 부저음이 발생합니다.\n확인 버튼을 눌러 주세요. + 누른 상태로 10초간 유지하세요. 10초 후 부저음이 한 번 더 울립니다. + + 바늘 삽입 재시도 횟수가 %d번 남았습니다. + 새 패치 사용을 위해 기존 패치를 사용 종료하시겠어요? + 확인 버튼을 누르면 인슐린 주입이 중단됩니다. + 아래 검색된 패치가 있다면 확인 버튼을 눌러 주세요. + + 바늘 삽입 완료 및 어플리케이터 분리 + 분리 완료 + 어플리케이터 양 옆 손잡이를 눌러 패치에서 분리하고\n[분리 완료] 버튼을 눌러주세요. 주입이 시작됩니다. + + + 블루투스가 활성화되어 있지 않습니다. 확인해 주세요. + 패치가 연결되어 있지 않습니다. + 패치가 아직 연결되지 않았습니다. 잠시 후 다시 시도해 주세요. + 스캔 실패했습니다. 다시 시도해 주세요. + 검색된 패치가 없습니다. 다시 시도해 주세요. + 패치 연결 실패했습니다. 다시 시도해 주세요. + + + 프로파일이 설정되어 있지 않습니다. + + + 바늘 삽입에 실패했습니다. + + + 패치 사용 종료에 실패했습니다. 다시 시도해 주세요. + + + 기저 주입 요청에 실패했습니다. 다시 시도해 주세요. + + + 주입 재개에 실패했습니다. 다시 시도해 주세요. + 주입 정지에 실패했습니다. 다시 시도해 주세요. + + + 안전 점검에 실패했습니다. + + 통신 점검 + 패치와 통신이 원활하지 않습니다.\n재연결을 원하시면 통신점검, 사용종료를 원하시면 사용 종료 버튼을 눌러주세요. + + 경고 + 주의 + 알림 + 알 수 없음 + + 발생 시간 + + 패치에 문제가 발생하셨나요?\n아래 버튼을 눌러 패치를 강제 종료해 주세요. + + 인슐린 부족 + 패치 사용 종료 + 배터리 부족 + 부적합 온도 + 앱 미사용 주입 차단 + BLE 연결 안됨 + 패치 적용 시간 종료 + 안전점검 실패 + 패치 오류 + 주입구 막힘 + 바늘 삽입 오류 + + 10 U 미만으로, 주입이 중단되었습니다.
사용 종료를 눌러 즉시 패치를 교체해 주세요.]]>
+ 사용 종료를 눌러 즉시 패치를 교체해 주세요.]]> + 강제 종료를 눌러 즉시 패치를 교체해 주세요.]]> + 즉시 적합한 온도(4.4도 이상 37도 이하)로 이동해 주세요.]]> + 주입 재개 버튼을 눌러 기저 주입을 다시 시작해 보세요.]]> + 사용 종료를 눌러 즉시 패치를 교체해 주세요.]]> + 사용 종료를 눌러 즉시 패치를 교체해 주세요.]]> + 사용 종료를 눌러 즉시 패치를 교체해 주세요.]]> + 사용 종료를 눌러 즉시 패치를 교체해 주세요.]]> + 사용 종료를 눌러 즉시 패치를 교체해 주세요.]]> + + 인슐린 부족 + 패치 사용 기간 만료 + 패치 사용 종료 임박 + 배터리 부족 + 부적합 온도 + 앱 미사용 주입 차단 + BLE 연결 안됨 + 패치 적용 미완료 + 주입 재개 + 블루투스 켜기 + + %s U 미만입니다.
패치 교체를 준비해 주세요.]]>
+ 새 패치로 교체해 주세요.]]> + 새 패치로 교체해 주세요.]]> + 패치 교체를 준비해 주세요.]]> + 즉시 적합한 온도(4.4도 이상 37도 이하)로 이동해 주세요.
적합한 온도가 되면 기저 주입이 재개됩니다.]]>
+ 확인 버튼을 누르지 않으면 경고 알람으로 상향되고
인슐린 주입이 차단됩니다.]]>
+ + 주입 재개를 눌러 기저 주입을 다시 시작해 주세요.]]> + 확인 버튼을 누르고 블루투스를 켜 주세요.]]> + + 인슐린 부족 + 패치 사용 기간 알림 + 패치 점검 알림 + 국가 표준시간 변경 + 혈당 확인 + LGS 작동 시작 + LGS 작동 종료 + LGS 작동 불가 + 패치 오류 + + 패치 교체를 준비해 주세요.]]> + %s일 %s시간이 되었습니다.
패치 사용에 참고해 주세요.]]>
+ %s(UTC %s)에서 %s(UTC %s)(으)로 국가 표준시간이 변경되었습니다.]]> + %s/%s(UTC %s)에서 %s/%s(UTC %s)(으)로 국가 표준시간이 변경되었습니다.]]> + %s이 지났습니다.
혈당을 확인해 주세요.]]>
+ LGS 작동이 시작 되었습니다. + 패치 또는 CGM 연결이 끊겨 LGS 작동이 종료 되었습니다. + LGS 일시 정지로 인하여 LGS 작동이 종료 되었습니다. + 설정한 LGS 작동 시간대가 경과되어 LGS 작동이 종료 되었습니다. + LGS 사용 설정을 OFF로 변경하여 LGS 작동이 종료 되었습니다. + CGM 혈당 값이 설정 값 이상으로 측정되어 LGS 작동이 종료 되었습니다. + 알수 없는 이유로 인해 LGS 작동 종료 되었습니다. + 패치 및 CGM 연결 상태를 확인해 주세요. + + 알 수 없는 패치 오류입니다. + + 사용 종료 + 강제 종료 + 주입 재개 + + 패치 연결 상태를 확인해 주세요. + 알림 확인 처리에 실패했습니다. 다시 시도 해주세요. + + + 패치에 인슐린이 10 U 미만 으로, 주입이 중단되었습니다. 사용 종료를 눌러 즉시 패치를 교체해 주세요. + 패치 사용 및 인슐린 주입이 중단되었습니다. 사용 종료를 눌러 즉시 패치를 교체해 주세요. + 패치 배터리가 부족하여 인슐린 주입이 중단되었습니다. 강제 종료를 눌러 즉시 패치를 교체해 주세요. + 인슐린 주입이 중단되었습니다. 즉시 적합한 온도(4.4도 이상 37도 이하)로 이동해 주세요. + 인슐린 주입이 중단되었습니다. 주입 재개 버튼을 눌러 기저 주입을 다시 시작해 보세요. + 패치 적용 시간이 만료되었습니다. 사용 종료를 눌러 즉시 패치를 교체해 주세요. + 안전점검이 실패했습니다. 사용 종료를 눌러 즉시 패치를 교체해 주세요. + 예상치 못한 오류로 인해 인슐린 주입이 중단되었습니다. 사용 종료를 눌러 즉시 패치를 교체해 주세요. + 주입구가 막혀 인슐린 주입이 중단되었습니다. 사용 종료를 눌러 즉시 패치를 교체해 주세요. + 바늘 삽입에 실패했습니다. 사용 종료를 눌러 즉시 패치를 교체해 주세요. + + 패치에 남은 인슐린이 %s U 미만입니다. 패치 교체를 준비해 주세요. + 패치 사용 기간이 만료되었습니다. 새 패치로 교체해 주세요. + 패치 사용이 곧 종료됩니다. 새 패치로 교체해 주세요. + 패치에 배터리가 부족합니다. 패치 교체를 준비해 주세요. + 인슐린 주입이 중단되었습니다. 즉시 적합한 온도(4.4도 이상 37도 이하)로 이동해 주세요. 적합한 온도가 되면 기저 주입이 재개됩니다. + 확인 버튼을 눌러주세요. 확인 버튼을 누르지 않으면 경고 알람으로 상향되고 인슐린 주입이 차단됩니다. + 확인 버튼을 눌러 패치 연결을 완료해 주세요. + 주입 정지가 종료되었습니다. 주입 재개를 눌러 기저 주입을 다시 시작해 주세요. + 패치 사용을 위해 블루투스가 필요합니다. 확인 버튼을 누르고 블루투스를 켜 주세요. + + 패치에 남은 인슐린이 %s U 미만입니다. 패치 교체를 준비해 주세요. + 패치 사용 기간이 %s일 %s시간이 되었습니다. 패치 사용에 참고해 주세요. + %s(UTC %s)에서 %s(UTC %s)(으)로 국가 표준시간이 변경되었습니다. + %s/%s(UTC %s)에서 %s/%s(UTC %s)(으)로 국가 표준시간이 변경되었습니다. + 볼러스 주입 후 %s이 지났습니다. 혈당을 확인해 주세요. + LGS 작동이 시작 되었습니다. + 패치 또는 CGM 연결이 끊겨 LGS 작동이 종료 되었습니다. + LGS 일시 정지로 인하여 LGS 작동이 종료 되었습니다. + 설정한 LGS 작동 시간대가 경과되어 LGS 작동이 종료 되었습니다. + LGS 사용 설정을 OFF로 변경하여 LGS 작동이 종료 되었습니다. + CGM 혈당 값이 설정 값 이상으로 측정되어 LGS 작동이 종료 되었습니다. + 알수 없는 이유로 인해 LGS 작동 종료 되었습니다. + 패치 및 CGM 연결 상태를 확인해 주세요. + + + 업데이트 요청 간격이 짧아 실행되지 않았습니다. + +
diff --git a/pump/carelevo/src/main/res/values/strings.xml b/pump/carelevo/src/main/res/values/strings.xml new file mode 100644 index 000000000000..22ece7804fb6 --- /dev/null +++ b/pump/carelevo/src/main/res/values/strings.xml @@ -0,0 +1,304 @@ + + Carelevo + CL + Pump integration for carelevo + + Carelevo alarms + Notifications for Carelevo patch alarms + + OK + + Bluetooth status + Serial No. + Firmware + Activation Time + Expiration + Basal Rate + Temp basal rate + Insulin Remaining + Patch Time Remaining + Total insulin delivered + + %d hrs %d min + %d min + %d hrs + %s U + %.2f U/hr + %1$s / %2$s + + Would you like to resume infusion? + Infusion will resume when you press Confirm. + + Please set the infusion pause time. + 30 minutes + 1 hour + 1 hour 30 minutes + 2 hours + 2 hours 30 minutes + 3 hours + 3 hours 30 minutes + 4 hours + + %1$d days %2$02d:%3$02d + %d min %02d sec remaining + %1$d/100 + + Insulin Guide + Deactivate Patch + Search Patch + Retry + Next + Safety Check + Cancel + Confirm + Rescan + Check Insertion + + No Active Patch + Disconnected + Connected + + Activate Patch + Discard Patch + + Select filled amount. + Range : %1$d ~ %2$d U + + 1. Peel off the backing paper from the patch blister. + 2. Disinfect the rubber cap of the insulin vial using an alcohol swab. + 3. Remove the needle protection cap of the insulin syringe by pulling it straight off. + 4. Pull back the syringe plunger to fill it with approximately 2 mL of air. + 5. Insert the air-filled syringe into the insulin vial and inject the air into the vial. + 6. With the syringe still inserted, flip both the vial and syringe upside down, and use the pressure to fill the syringe with approximately 2 mL of insulin. + 7. Insert the syringe needle vertically into the patch\'s insulin fill port (①) until it reaches the bottom. Pull the plunger all the way up, then release it. Remove the syringe from the fill port. + 8. Hold the syringe upright and tap the side gently to collect all air bubbles at the top. + 9. Insert the needle back into the insulin vial, push all the air into the vial, and then draw the specific amount of insulin you will use for the duration of the patch. + 10. Remove the syringe from the vial. If air bubbles are present, hold the syringe upright, tap the side to move bubbles to the top, and expel all air from the syringe. + 11. Align the top of the plunger with the scale mark to precisely adjust the insulin dose. + 12. Insert the needle vertically into the patch\'s insulin fill port (①) until it reaches the bottom, and slowly push the plunger to inject all the insulin into the patch. + + Patch Started + Connect Patch + Safety Check + Apply Patch + Insert Needle + + Patch Info Alarm + Low Insulin Notification Setting + Patch Usage Notification Setting + + + Fill Patch with Insulin + Fill the patch with insulin (%1$d–%2$d U), considering the usage period (up to 168 hours). Tap the [Fill Amount] for insulin filled. + + STEP 1. + STEP 2. + STEP 3. + + Remove Needle Cap + Take out patch from blister and remove needle cap. + Power On and Connect + Press the power button for more than 1 second. After the beep, Tap the [Search Patch] button below. + + Safety Check + Safety Check Completed + The patch has been connected successfully.\nPlease press the [Safety Check] button. + Safety check in progress. + Safety check completed.\nPlease press the Next button. + Please check whether an insulin droplet has formed at the needle tip. + If no insulin droplet is visible, please press the [Retry] button. + + Disinfect Patch Site + Clean thoroughly with an alcohol swab. + Remove Backing Tape + Take out patch from blister and remove needle cap. + Apply Patch + Attach the patch to your skin with the marked dot facing upward. + After attaching the patch, press the Ready button. + + Remove Safety Cap + Turn the safety cap in the direction of the arrow and remove. + Insert Needle + Press the applicator push button all the way down. A beep will sound upon successful insertion. + Press and hold for 10 seconds. + + %d retry attempt(s) left for needle insertion. + Deactivate patch and start new patch? + Pressing the [OK] button will stop insulin. + If the patch detected below is correct, please tap the Confirm button. + + Needle insertion complete and applicator removal + + Applicator Removed + + + Press the grips on both sides of the applicator to detach it from the patch,\n + and then press the `Applicator removed` button. Insulin delivery will begin. + + + + Bluetooth is not enabled. Please check. + No patch connected. + The patch is not connected yet. Please try again in a moment. + Scan failed. Please try again. + No patch was found. Please try again. + Patch connection failed. Please try again. + + + Profile is not set. + + + Needle check failed. + + + Patch discard failed. Please try again. + + + Basal delivery request failed. Please try again. + + + Failed to resume delivery. Please try again. + Failed to suspend delivery. Please try again. + + + Safety check failed. + + Communication Check + Communication with the patch is not stable.\nIf you want to reconnect, tap Communication Check. If you want to stop using it, tap End Use. + Patch address is unavailable. Please reconnect the patch. + Communication check failed. Please try again. + + + Critical + Advisory + Notification + Unknown + + Alarm Time + + Unable to connect to patch\nTap the confirm button below to discard the patch. + + + Low Insulin + Patch Expired + Low Battery + High/Low Temperature + Auto-Off + BLE Not Connected + Patch Application Timeout + Self-Diagnosis Failed + Patch Error + Occlusion + Needle Insertion Error + + + 10 U insulin remaining; insulin delivery stopped.
Tap the [Deactivate Patch] button and change patch now.]]>
+ Tap the [Deactivate Patch] button and change patch now.]]> + Tap the [Discard Patch] button and change patch now.]]> + Move to a suitable temperature now (40℉ ~ 99℉).]]> + Tap the [Start Insulin] button and restart basal delivery.]]> + Tap the [Deactivate Patch] button and change patch now.]]> + Tap the [Deactivate Patch] button and change patch now.]]> + Tap the [Deactivate Patch] button and change patch now.]]> + Tap the [Deactivate Patch] button and change patch now.]]> + Tap the [Deactivate Patch] button and change patch now.]]> + + + Low Insulin + Patch Operating Life Expired + Patch Will Expire Soon + Low Battery + High/Low Temperature + Auto-Off + BLE Not Connected + Patch Activation Incomplete + Start Insulin + Turn Bluetooth On + + + %s U insulin remaining.
Prepare to change the patch.]]>
+ Change patch now.]]> + Change patch now.]]> + Prepare to change the patch.]]> +
Move to 40℉–99℉ to resume.]]>
+ If ignored, it escalates to a critical alarm and insulin delivery will be stopped.]]> + + Tap [Start Insulin] to start.]]> + Tap [OK] and turn Bluetooth on.]]> + + + Low Insulin + Patch Usage Time Notification + Patch Check Notification + Time Zone Change + Check Blood Glucose + LGS Started + LGS Ended + LGS Not Available + Patch Error + + + Prepare to change the patch.]]> + %s days %s hours..
Keep in mind during use.]]>
+ %s(UTC %s) to %s(UTC %s).]]> + %s/%s(UTC %s) to %s/%s(UTC %s).]]> + Please check your blood glucose.]]> + LGS started. + LGS ended due to patch/CGM disconnection. + LGS ended due to LGS pause. + LGS ended because scheduled time has elapsed. + LGS ended because LGS was turned off. + LGS ended because CGM level exceeded the set threshold. + LGS ended due to an unknown reason. + Please check patch and CGM connection. + + Unexpected patch error. + + + Deactivate Patch + Discard Patch + Start Insulin + + + Please check patch connection. + Failed to confirm alarm/notification. Please try again. + + + Less than 10 U insulin remaining; insulin delivery stopped. Tap the [Deactivate Patch] button and change patch now. + Patch expired; insulin delivery stopped. Tap the [Deactivate Patch] button and change patch now. + Low battery; insulin delivery stopped. Tap the [Discard Patch] button and change patch now. + Insulin delivery stopped. Move to a suitable temperature now (40℉ ~ 99℉). + Insulin delivery stopped. Tap the [Start Insulin] button and restart basal delivery. + Patch application time ran out. Tap the [Deactivate Patch] button and change patch now. + Self-diagnosis failed. Tap the [Deactivate Patch] button and change patch now. + Insulin delivery stopped due to unexpected error. Tap the [Deactivate Patch] button and change patch now. + Insulin delivery stopped due to occlusion. Tap the [Deactivate Patch] button and change patch now. + Failed to insert the needle. Tap the [Deactivate Patch] button and change patch now. + + Less than %s U insulin remaining. Prepare to change the patch. + Patch expired. Change patch now. + Patch will expire soon. Change patch now. + Battery is low. Prepare to change the patch. + Insulin delivery paused due to extreme temperature. Move to 40℉–99℉ to resume. + Please tap [OK]. If ignored, it escalates to a critical alarm and insulin delivery will be stopped. + Tap the [OK] button to complete the patch activation. + Pause Insulin has ended. Tap [Start Insulin] to start. + Bluetooth is required to use the patch. Tap [OK] and turn Bluetooth on. + + Less than %s U insulin remaining. Prepare to change the patch. + Patch usage time is %s days %s hours. Keep in mind during use. + Time zone has changed from %s(UTC %s) to %s(UTC %s). + Time zone has changed from %s/%s(UTC %s) to %s/%s(UTC %s). + %s have passed since your bolus. Please check your blood glucose. + LGS started. + LGS ended due to patch/CGM disconnection. + LGS ended due to LGS pause. + LGS ended because scheduled time has elapsed. + LGS ended because LGS was turned off. + LGS ended because CGM level exceeded the set threshold. + LGS ended due to an unknown reason. + Please check patch and CGM connection. + + Skipped: update too soon. + +
diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginBolusTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginBolusTest.kt new file mode 100644 index 000000000000..2cb159985888 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginBolusTest.kt @@ -0,0 +1,190 @@ +package app.aaps.pump.carelevo + +import app.aaps.core.interfaces.pump.DetailedBolusInfo +import app.aaps.pump.carelevo.ble.commands.ExtendBolusCancelCommand +import app.aaps.pump.carelevo.ble.commands.ExtendBolusCommand +import app.aaps.pump.carelevo.ble.commands.ExtendBolusResponse +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoImmeBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.isA +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import java.util.Optional + +class CarelevoPumpPluginBolusTest : CarelevoPumpPluginTestBase() { + + @Test + fun `deliverTreatment should require insulin greater than zero`() { + val bolusInfo = DetailedBolusInfo().apply { + insulin = 0.0 + carbs = 0.0 + } + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { plugin.deliverTreatment(bolusInfo) } + } + } + + @Test + fun `deliverTreatment should require carbs equal to zero`() { + val bolusInfo = DetailedBolusInfo().apply { + insulin = 1.0 + carbs = 10.0 + } + + assertThrows(IllegalArgumentException::class.java) { + runBlocking { plugin.deliverTreatment(bolusInfo) } + } + } + + @Test + fun `deliverTreatment should return not enacted when bluetooth is disabled`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(false) + + val result = runBlocking { + plugin.deliverTreatment(DetailedBolusInfo().apply { + insulin = 1.0 + carbs = 0.0 + }) + } + + assertThat(result.enacted).isFalse() + } + + @Test + fun `deliverTreatment should return not enacted when no patch address is stored`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + + val result = runBlocking { + plugin.deliverTreatment(DetailedBolusInfo().apply { + insulin = 1.0 + carbs = 0.0 + }) + } + + assertThat(result.enacted).isFalse() + assertThat(result.bolusDelivered).isWithin(0.001).of(0.0) + } + + @Test + fun `deliverTreatment should reject when immediate bolus is already running`() { + infusionInfoSubject.onNext( + Optional.of( + CarelevoInfusionInfoDomainModel( + immeBolusInfusionInfo = CarelevoImmeBolusInfusionInfoDomainModel( + infusionId = "imme-1", + address = "AA:BB:CC:DD:EE:FF", + mode = 3, + volume = 1.0, + infusionDurationSeconds = 30 + ) + ) + ) + ) + + val result = runBlocking { + plugin.deliverTreatment(DetailedBolusInfo().apply { + insulin = 1.0 + carbs = 0.0 + }) + } + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(result.bolusDelivered).isWithin(0.001).of(0.0) + } + + @Test + fun `setExtendedBolus should succeed when the pump accepts the command`() { + val result = runBlocking { plugin.setExtendedBolus(insulin = 1.2, durationInMinutes = 30) } + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + } + + @Test + fun `setExtendedBolus should fail when the pump rejects the command`() { + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenReturn(ExtendBolusResponse(resultCode = 1, expectedTimeSeconds = 0)) + + val result = runBlocking { plugin.setExtendedBolus(insulin = 1.2, durationInMinutes = 30) } + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + } + + @Test + fun `cancelExtendedBolus should succeed when the pump accepts the command`() { + val result = runBlocking { plugin.cancelExtendedBolus() } + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + assertThat(result.isTempCancel).isTrue() + } + + @Test + fun `setExtendedBolus should require a positive duration`() { + // durationInMinutes == 0 would otherwise encode speed = Infinity onto the wire. + assertThrows(IllegalArgumentException::class.java) { + runBlocking { plugin.setExtendedBolus(insulin = 1.0, durationInMinutes = 0) } + } + } + + @Test + fun `deliverTreatment should continue and record the bolus when the local persist fails after the pump ACK`() { + // Pump ACKed (resultCode==0 stubbed in the base) — insulin IS being delivered. A failed + // local persist must NOT turn that into "nothing happened": the delivery loop still runs + // and the bolus still reaches pumpSync, or IOB would silently lose a delivered dose. + whenever(startImmeBolusInfusionUseCase.persistImmeBolusStarted(any(), any(), any())).thenReturn(false) + + val result = runBlocking { + plugin.deliverTreatment(DetailedBolusInfo().apply { + insulin = 0.25 + carbs = 0.0 + }) + } + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + assertThat(result.bolusDelivered).isWithin(0.001).of(0.25) + verifyBlocking(pumpSync) { syncBolusWithPumpId(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `setExtendedBolus should record in pumpSync even when the local persist fails`() { + whenever(startExtendBolusInfusionUseCase.persistExtendBolusStarted(any(), any(), any())).thenReturn(false) + + val result = runBlocking { plugin.setExtendedBolus(insulin = 1.2, durationInMinutes = 30) } + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + verifyBlocking(pumpSync) { syncExtendedBolusWithPumpId(any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `cancelExtendedBolus should record the stop in pumpSync even when the local persist fails`() { + whenever(cancelExtendBolusInfusionUseCase.persistExtendBolusCancelled()).thenReturn(false) + + val result = runBlocking { plugin.cancelExtendedBolus() } + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + verifyBlocking(pumpSync) { syncStopExtendedBolusWithPumpId(any(), any(), any(), any()) } + } + + @Test + fun `cancelExtendedBolus should fail when the session throws`() { + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenAnswer { throw IllegalStateException("failed") } + + val result = runBlocking { plugin.cancelExtendedBolus() } + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginLifecycleTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginLifecycleTest.kt new file mode 100644 index 000000000000..95f264b656c1 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginLifecycleTest.kt @@ -0,0 +1,1021 @@ +package app.aaps.pump.carelevo + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import app.aaps.core.interfaces.configuration.ExternalOptions +// Aliased: the simple name collides with Robolectric's @Config annotation used below. +import app.aaps.core.interfaces.configuration.Config as AapsConfig +import android.content.Context +import android.content.Intent +import android.os.Looper +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ProcessLifecycleOwner +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.profile.EffectiveProfile +import app.aaps.core.interfaces.profile.Profile +import app.aaps.core.interfaces.profile.ProfileFunction +import app.aaps.core.interfaces.protection.ProtectionCheck +import app.aaps.core.interfaces.pump.BlePreCheck +import app.aaps.core.interfaces.pump.BolusProgressData +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.queue.CustomCommand +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.core.interfaces.ui.IconsProvider +import app.aaps.core.interfaces.ui.UiInteraction +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.interfaces.utils.fabric.FabricPrivacy +import app.aaps.core.keys.DoubleKey +import app.aaps.core.keys.IntKey +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.core.ui.R as CoreUiR +import app.aaps.pump.carelevo.ble.CarelevoBleSession +import app.aaps.pump.carelevo.ble.data.BleState +import app.aaps.pump.carelevo.ble.data.BondingState +import app.aaps.pump.carelevo.ble.data.DeviceModuleState +import app.aaps.pump.carelevo.ble.data.NotificationState +import app.aaps.pump.carelevo.ble.data.PeripheralConnectionState +import app.aaps.pump.carelevo.ble.data.ServiceDiscoverState +import app.aaps.pump.carelevo.command.CarelevoActivationExecutor +import app.aaps.pump.carelevo.command.CmdUpdateBuzzer +import app.aaps.pump.carelevo.command.CmdUpdateExpiredThreshold +import app.aaps.pump.carelevo.command.CmdUpdateLowInsulinNotice +import app.aaps.pump.carelevo.command.CmdUpdateMaxBolus +import app.aaps.pump.carelevo.common.CarelevoAlarmNotifier +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.common.keys.CarelevoBooleanPreferenceKey +import app.aaps.pump.carelevo.common.keys.CarelevoIntPreferenceKey +import app.aaps.pump.carelevo.common.model.PatchState +import app.aaps.pump.carelevo.coordinator.CarelevoBasalProfileUpdateCoordinator +import app.aaps.pump.carelevo.coordinator.CarelevoBolusCoordinator +import app.aaps.pump.carelevo.coordinator.CarelevoConnectionCoordinator +import app.aaps.pump.carelevo.coordinator.CarelevoSettingsCoordinator +import app.aaps.pump.carelevo.coordinator.CarelevoTempBasalCoordinator +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoExtendBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoImmeBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.model.result.ResultSuccess +import app.aaps.pump.carelevo.domain.model.userSetting.CarelevoUserSettingInfoDomainModel +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.usecase.basal.CarelevoCancelTempBasalInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.basal.CarelevoSetBasalProgramUseCase +import app.aaps.pump.carelevo.domain.usecase.basal.CarelevoStartTempBasalInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoCancelExtendBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoCancelImmeBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoFinishImmeBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoStartExtendBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoStartImmeBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoDeleteUserSettingInfoUseCase +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.BehaviorSubject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.joda.time.DateTime +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.timeout +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import java.util.Optional +import javax.inject.Provider + +/** + * Robolectric unit tests for the LIFECYCLE half of [CarelevoPumpPlugin] — everything `onStart()` + * wires up and `onStop()` tears down, which the JVM/Mockito suites + * ([CarelevoPumpPluginTest] and friends) cannot reach: + * + * - `onStart`: the one-shot CAGE threshold defaults, `initPatchOnce` + best-effort profile apply, + * the reservoir/battery mirror, the Bluetooth adapter seed + broadcast receiver, and the alarm + * observer registration. + * - the four preference observers (max bolus / expiry threshold / low-insulin zero-skip / buzzer) + * and the deferred settings-sync recovery combiner. + * - `handleAlarms` — the critical-alarm escalation to `UiInteraction.runAlarm` when the Compose + * host is NOT mounted, plus the `globallyAlarmedIds` dedup/prune. + * - `onStop`: alarm-observer teardown, user-settings clear and preference-scope cancellation. + * + * Robolectric is required because `startAlarmObserving`/`onStop` touch [ProcessLifecycleOwner] + * (a real `Looper` + `ArchTaskExecutor` main-thread check) and `PumpPluginBase.onStart` builds a + * real `HandlerThread`. Same rationale and style as + * [app.aaps.pump.carelevo.common.CarelevoAlarmNotifierTest]. + * + * `Dispatchers.setMain(UnconfinedTestDispatcher())` backs the `withContext(Dispatchers.Main)` hops + * in `startAlarmObserving`/`onStop`: without it the real Android main dispatcher would post to the + * paused Robolectric looper while `runBlocking` holds that very thread, and deadlock. + * + * The Rx schedulers are trampolined so `initPatchOnce`, the patchInfo mirror and the settings-sync + * combiner all deliver synchronously. The preference observers are the exception — the plugin + * collects them on a hard-coded `Dispatchers.IO` scope, so those are verified with `timeout(...)`. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +@OptIn(ExperimentalCoroutinesApi::class) +class CarelevoPumpPluginLifecycleTest { + + // REAL application context: getSystemService(BLUETOOTH_SERVICE) and registerReceiver must work. + private val context: Context = RuntimeEnvironment.getApplication() + + private lateinit var aapsLogger: AAPSLogger + private lateinit var rh: ResourceHelper + private lateinit var preferences: Preferences + private lateinit var commandQueue: CommandQueue + private lateinit var aapsSchedulers: AapsSchedulers + private lateinit var sp: SP + private lateinit var spEditor: SP.Editor + private lateinit var fabricPrivacy: FabricPrivacy + private lateinit var profileFunction: ProfileFunction + private lateinit var protectionCheck: ProtectionCheck + private lateinit var blePreCheck: BlePreCheck + private lateinit var iconsProvider: IconsProvider + private lateinit var config: AapsConfig + private lateinit var uiInteraction: UiInteraction + private lateinit var carelevoPatch: CarelevoPatch + private lateinit var carelevoAlarmNotifier: CarelevoAlarmNotifier + private lateinit var bleSession: CarelevoBleSession + private lateinit var activationExecutor: CarelevoActivationExecutor + private lateinit var deleteUserSettingInfoUseCase: CarelevoDeleteUserSettingInfoUseCase + + private lateinit var plugin: CarelevoPumpPlugin + + // EffectiveProfile, not plain Profile: ProfileFunction.getProfile() returns EffectiveProfile? and + // the same mock also backs carelevoPatch.profile (EffectiveProfile : Profile). + private lateinit var testProfile: EffectiveProfile + + // Preference streams the plugin collects in registerPreferenceChangeObserver(). + private val maxBolusFlow = MutableStateFlow(3.0) + private val expiryFlow = MutableStateFlow(116) + private val lowInsulinFlow = MutableStateFlow(30) + private val buzzerFlow = MutableStateFlow(false) + + // Patch streams the deferred settings-sync combiner reads. + private lateinit var patchInfoSubject: BehaviorSubject> + private lateinit var infusionInfoSubject: BehaviorSubject> + private lateinit var patchStateSubject: BehaviorSubject> + private lateinit var userSettingSubject: BehaviorSubject> + private lateinit var profileSubject: BehaviorSubject> + + private var started = false + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + // ProcessLifecycleOwner is a PROCESS singleton and survives between test methods, so pin it to + // a known background state: addObserver replays events up to the current state, and a leftover + // STARTED from another test would fire ON_START (-> refreshAlarms) the moment onStart registers. + (ProcessLifecycleOwner.get().lifecycle as LifecycleRegistry).handleLifecycleEvent(Lifecycle.Event.ON_STOP) + + aapsLogger = mock() + rh = mock() + preferences = mock() + commandQueue = mock() + aapsSchedulers = mock() + sp = mock() + spEditor = mock() + fabricPrivacy = mock() + profileFunction = mock() + protectionCheck = mock() + blePreCheck = mock() + iconsProvider = mock() + config = mock() + uiInteraction = mock() + carelevoPatch = mock() + carelevoAlarmNotifier = mock() + bleSession = mock() + activationExecutor = mock() + deleteUserSettingInfoUseCase = mock() + + whenever(aapsSchedulers.io).thenReturn(Schedulers.trampoline()) + whenever(aapsSchedulers.main).thenReturn(Schedulers.trampoline()) + whenever(aapsSchedulers.cpu).thenReturn(Schedulers.trampoline()) + whenever(aapsSchedulers.newThread).thenReturn(Schedulers.trampoline()) + whenever(rh.gs(any())).thenReturn("Mocked") + + testProfile = mock() + whenever(testProfile.getBasal()).thenReturn(1.0) + + patchInfoSubject = BehaviorSubject.createDefault(Optional.of(samplePatchInfo())) + infusionInfoSubject = BehaviorSubject.createDefault(Optional.of(CarelevoInfusionInfoDomainModel())) + patchStateSubject = BehaviorSubject.createDefault(Optional.of(PatchState.ConnectedBooted)) + userSettingSubject = BehaviorSubject.createDefault(Optional.of(CarelevoUserSettingInfoDomainModel())) + profileSubject = BehaviorSubject.createDefault(Optional.of(testProfile)) + + whenever(carelevoPatch.patchInfo).thenReturn(patchInfoSubject) + whenever(carelevoPatch.infusionInfo).thenReturn(infusionInfoSubject) + whenever(carelevoPatch.patchState).thenReturn(patchStateSubject) + whenever(carelevoPatch.userSettingInfo).thenReturn(userSettingSubject) + whenever(carelevoPatch.profile).thenReturn(profileSubject) + whenever(carelevoPatch.initPatchOnce()).thenReturn(Completable.complete()) + + // Deferred settings-sync + preference observers both enqueue through the queue. + val enactResult: PumpEnactResult = mock() + whenever { commandQueue.customCommand(any()) }.thenReturn(enactResult) + + // onStop -> settingsCoordinator.clearUserSettings + whenever(deleteUserSettingInfoUseCase.execute()).thenReturn(Single.just(ResponseResult.Success(ResultSuccess))) + + whenever(preferences.observe(DoubleKey.SafetyMaxBolus)).thenReturn(maxBolusFlow) + whenever(preferences.observe(CarelevoIntPreferenceKey.CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS)).thenReturn(expiryFlow) + whenever(preferences.observe(CarelevoIntPreferenceKey.CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS)).thenReturn(lowInsulinFlow) + whenever(preferences.observe(CarelevoBooleanPreferenceKey.CARELEVO_BUZZER_REMINDER)).thenReturn(buzzerFlow) + whenever(preferences.get(DoubleKey.SafetyMaxBolus)).thenReturn(7.5) + + // sp.edit { … } returns Unit -> stub with doAnswer and run the block against a mock Editor. + doAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + (invocation.getArgument(1) as SP.Editor.() -> Unit).invoke(spEditor) + Unit + }.whenever(sp).edit(any(), any()) + + plugin = buildPlugin() + plugin.bleSession = bleSession + } + + @After + fun tearDown() { + // Cancel the IO preference scope, PumpPluginBase's delayed initial-readStatus job and the + // ProcessLifecycleOwner observer, so nothing leaks into the next test in this class. + if (started) runBlocking { plugin.onStop() } + Dispatchers.resetMain() + } + + private fun buildPlugin(): CarelevoPumpPlugin { + val pumpEnactResultProvider = Provider { mock() } + val setBasalProgramUseCase: CarelevoSetBasalProgramUseCase = mock() + val startTempBasalInfusionUseCase: CarelevoStartTempBasalInfusionUseCase = mock() + val cancelTempBasalInfusionUseCase: CarelevoCancelTempBasalInfusionUseCase = mock() + val startImmeBolusInfusionUseCase: CarelevoStartImmeBolusInfusionUseCase = mock() + val startExtendBolusInfusionUseCase: CarelevoStartExtendBolusInfusionUseCase = mock() + val cancelImmeBolusInfusionUseCase: CarelevoCancelImmeBolusInfusionUseCase = mock() + val cancelExtendBolusInfusionUseCase: CarelevoCancelExtendBolusInfusionUseCase = mock() + val finishImmeBolusInfusionUseCase: CarelevoFinishImmeBolusInfusionUseCase = mock() + val dateUtil: DateUtil = mock() + val pumpSync: PumpSync = mock() + val bolusProgressData: BolusProgressData = mock() + + return CarelevoPumpPlugin( + aapsLogger = aapsLogger, + rh = rh, + preferences = preferences, + commandQueue = commandQueue, + aapsSchedulers = aapsSchedulers, + sp = sp, + fabricPrivacy = fabricPrivacy, + profileFunction = profileFunction, + context = context, + protectionCheck = protectionCheck, + blePreCheck = blePreCheck, + iconsProvider = iconsProvider, + config = config, + uiInteraction = uiInteraction, + pumpEnactResultProvider = pumpEnactResultProvider, + carelevoPatch = carelevoPatch, + carelevoAlarmNotifier = carelevoAlarmNotifier, + basalProfileUpdateCoordinator = CarelevoBasalProfileUpdateCoordinator( + aapsLogger = aapsLogger, + rh = rh, + pumpEnactResultProvider = pumpEnactResultProvider, + carelevoPatch = carelevoPatch, + bleSession = bleSession, + setBasalProgramUseCase = setBasalProgramUseCase + ), + bolusCoordinator = CarelevoBolusCoordinator( + aapsLogger = aapsLogger, + rh = rh, + dateUtil = dateUtil, + bolusProgressData = bolusProgressData, + pumpSync = pumpSync, + aapsSchedulers = aapsSchedulers, + pumpEnactResultProvider = pumpEnactResultProvider, + carelevoPatch = carelevoPatch, + bleSession = bleSession, + startImmeBolusInfusionUseCase = startImmeBolusInfusionUseCase, + finishImmeBolusInfusionUseCase = finishImmeBolusInfusionUseCase, + cancelImmeBolusInfusionUseCase = cancelImmeBolusInfusionUseCase, + startExtendBolusInfusionUseCase = startExtendBolusInfusionUseCase, + cancelExtendBolusInfusionUseCase = cancelExtendBolusInfusionUseCase + ), + tempBasalCoordinator = CarelevoTempBasalCoordinator( + aapsLogger = aapsLogger, + dateUtil = dateUtil, + pumpSync = pumpSync, + pumpEnactResultProvider = pumpEnactResultProvider, + carelevoPatch = carelevoPatch, + bleSession = bleSession, + startTempBasalInfusionUseCase = startTempBasalInfusionUseCase, + cancelTempBasalInfusionUseCase = cancelTempBasalInfusionUseCase + ), + connectionCoordinator = CarelevoConnectionCoordinator( + aapsLogger = aapsLogger, + carelevoPatch = carelevoPatch, + bleSession = bleSession + ), + settingsCoordinator = CarelevoSettingsCoordinator( + aapsLogger = aapsLogger, + aapsSchedulers = aapsSchedulers, + deleteUserSettingInfoUseCase = deleteUserSettingInfoUseCase + ), + activationExecutor = activationExecutor + ) + } + + // ---- helpers ------------------------------------------------------------------------------ + + private fun samplePatchInfo(insulinRemain: Double = 60.0): CarelevoPatchInfoDomainModel = + CarelevoPatchInfoDomainModel( + address = "AA:BB:CC:DD:EE:FF", + createdAt = DateTime.now().minusHours(1), + updatedAt = DateTime.now(), + manufactureNumber = "CARELEVO-TEST-001", + insulinRemain = insulinRemain, + bolusActionSeq = 1, + mode = 1 + ) + + private fun alarm(cause: AlarmCause, id: String = "alarm-1"): CarelevoAlarmInfo = + CarelevoAlarmInfo( + alarmId = id, + alarmType = cause.alarmType, + cause = cause, + value = null, + createdAt = "2026-07-16T10:00:00", + updatedAt = "2026-07-16T10:00:00", + isAcknowledged = false + ) + + private fun start() { + runBlocking { plugin.onStart() } + started = true + } + + /** + * Capture the `onAlarmsUpdated` callback `startAlarmObserving` hands to the notifier — that lambda + * IS the plugin's private `handleAlarms`, and invoking it here runs it synchronously on the test + * thread. + */ + private fun alarmHandler(): (List) -> Unit { + val captor = argumentCaptor<(List) -> Unit>() + verify(carelevoAlarmNotifier).startObserving(captor.capture()) + return captor.firstValue + } + + /** + * Block until the plugin's collector for [flow] is attached AND has consumed the initial value + * that `drop(1)` swallows; only after that does a `value = …` change reach the `onEach` body. + * + * The plugin collects on its own `CoroutineScope(Dispatchers.IO)`, so a change published too + * early would be conflated into the collector's first emission and dropped. `subscriptionCount` + * flips at slot allocation, an instant before the initial value is read — the short settle + * covers that window. Landing inside it can only ever fail a test (the `timeout(…)` verify never + * fires), never pass one falsely. + */ + private fun awaitCollector(flow: MutableStateFlow<*>) { + val deadline = System.currentTimeMillis() + 2_000 + while (flow.subscriptionCount.value == 0 && System.currentTimeMillis() < deadline) Thread.sleep(2) + assertThat(flow.subscriptionCount.value).isGreaterThan(0) + Thread.sleep(100) + } + + /** + * Block until the plugin's collector for [flow] is gone. `scope.cancel()` returns immediately but + * the collector unwinds (and frees its slot) on its own IO thread, so only once the count is back + * to zero can a later change provably not reach a live collector. + */ + private fun awaitCollectorGone(flow: MutableStateFlow<*>) { + val deadline = System.currentTimeMillis() + 2_000 + while (flow.subscriptionCount.value > 0 && System.currentTimeMillis() < deadline) Thread.sleep(2) + assertThat(flow.subscriptionCount.value).isEqualTo(0) + } + + /** Every [CustomCommand] of type [T] the plugin has enqueued so far. */ + private inline fun capturedCommands(): List { + val captor = argumentCaptor() + verifyBlocking(commandQueue, atLeastOnce()) { customCommand(captor.capture()) } + return captor.allValues.filterIsInstance() + } + + // ---- onStart: CAGE defaults ---------------------------------------------------------------- + + @Test + fun `onStart applies the Carelevo CAGE warning and critical defaults once`() { + whenever(sp.getBoolean(eq(CarelevoBooleanPreferenceKey.CARELEVO_CAGE_DEFAULT_APPLIED.key), any())).thenReturn(false) + + start() + + verify(spEditor).putInt(IntKey.OverviewCageWarning.key, 96) + verify(spEditor).putInt(IntKey.OverviewCageCritical.key, 168) + // The latch itself must be written, or the defaults would stomp the user's edits every start. + verify(spEditor).putBoolean(CarelevoBooleanPreferenceKey.CARELEVO_CAGE_DEFAULT_APPLIED.key, true) + } + + @Test + fun `onStart does not re-apply the CAGE defaults once the latch is set`() { + whenever(sp.getBoolean(eq(CarelevoBooleanPreferenceKey.CARELEVO_CAGE_DEFAULT_APPLIED.key), any())).thenReturn(true) + + start() + + verify(sp, never()).edit(any(), any()) + } + + // ---- onStart: patch init + profile --------------------------------------------------------- + + @Test + fun `onStart initializes the patch and applies the current profile`() { + whenever { profileFunction.getProfile() }.thenReturn(testProfile) + + start() + + verify(carelevoPatch).initPatchOnce() + verify(carelevoPatch).setProfile(testProfile) + } + + @Test + fun `onStart defers the profile to setNewBasalProfile when none is available yet`() { + // profileFunction.getProfile() answers null on the un-stubbed mock: the profile store is not up + // yet. onStart must still complete — the profile arrives later via setNewBasalProfile. + start() + + verify(carelevoPatch).initPatchOnce() + verify(carelevoPatch, never()).setProfile(any()) + } + + @Test + fun `onStart survives a failing patch init and still registers the alarm observer`() { + // initPatchOnce is onErrorComplete()d + timed out: a broken init must not abort onStart or the + // alarm pipeline would never come up. + whenever(carelevoPatch.initPatchOnce()).thenReturn(Completable.error(IllegalStateException("boom"))) + + start() + + verify(carelevoAlarmNotifier).startObserving(any()) + } + + @Test + fun `onStart mirrors the patch reservoir and battery level`() { + patchInfoSubject.onNext(Optional.of(samplePatchInfo(insulinRemain = 42.5))) + + start() + + assertThat(plugin.reservoirLevel.value.cU).isWithin(0.001).of(42.5) + assertThat(plugin.batteryLevel.value).isEqualTo(0) + } + + @Test + fun `onStart reports a zero reservoir when no patch record exists`() { + patchInfoSubject.onNext(Optional.empty()) + + start() + + assertThat(plugin.reservoirLevel.value.cU).isWithin(0.001).of(0.0) + } + + @Test + fun `reservoir level keeps tracking the patch after onStart`() { + start() + + patchInfoSubject.onNext(Optional.of(samplePatchInfo(insulinRemain = 12.25))) + + assertThat(plugin.reservoirLevel.value.cU).isWithin(0.001).of(12.25) + } + + // ---- onStart: Bluetooth adapter seed + receiver --------------------------------------------- + + /** + * Pin the adapter explicitly rather than leaning on a Robolectric default — the default is OFF, + * and an implicit one silently decides which branch of `currentAdapterBleState()` a test covers. + */ + private fun setAdapterEnabled(enabled: Boolean) { + val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter + Shadows.shadowOf(adapter).setEnabled(enabled) + } + + @Test + fun `onStart seeds the adapter state so patchState resolves before the first broadcast`() { + setAdapterEnabled(true) + + start() + + val captor = argumentCaptor() + verify(carelevoPatch, atLeastOnce()).onBluetoothStateChanged(captor.capture()) + val seeded = captor.firstValue + assertThat(seeded.isEnabled).isEqualTo(DeviceModuleState.DEVICE_STATE_ON) + // Adapter-level ONLY: bond/discovery/connection/notification are per-session and never tracked. + assertThat(seeded.isBonded).isEqualTo(BondingState.BOND_NONE) + assertThat(seeded.isServiceDiscovered).isEqualTo(ServiceDiscoverState.DISCOVER_STATE_NONE) + assertThat(seeded.isConnected).isEqualTo(PeripheralConnectionState.CONN_STATE_NONE) + assertThat(seeded.isNotificationEnabled).isEqualTo(NotificationState.NOTIFICATION_NONE) + } + + @Test + fun `onStart seeds OFF when the adapter is disabled`() { + setAdapterEnabled(false) + + start() + + val captor = argumentCaptor() + verify(carelevoPatch, atLeastOnce()).onBluetoothStateChanged(captor.capture()) + assertThat(captor.firstValue.isEnabled).isEqualTo(DeviceModuleState.DEVICE_STATE_OFF) + } + + @Test + fun `while emulating the adapter is reported ON even though it is disabled`() { + // The emulated patch has no radio behind it, so every isBluetoothEnabled() gate in the + // coordinators and view models would otherwise refuse before reaching the emulated transport. + whenever(config.isEnabled(ExternalOptions.EMULATE_CARELEVO)).thenReturn(true) + setAdapterEnabled(false) + + start() + + val captor = argumentCaptor() + verify(carelevoPatch, atLeastOnce()).onBluetoothStateChanged(captor.capture()) + assertThat(captor.firstValue.isEnabled).isEqualTo(DeviceModuleState.DEVICE_STATE_ON) + } + + @Test + fun `while emulating a host adapter OFF broadcast cannot kill the session`() { + whenever(config.isEnabled(ExternalOptions.EMULATE_CARELEVO)).thenReturn(true) + setAdapterEnabled(false) + start() + clearInvocations(carelevoPatch) + + sendAdapterState(BluetoothAdapter.STATE_OFF) + + // The receiver is never registered while emulating, so the broadcast reaches nothing. + verify(carelevoPatch, never()).onBluetoothStateChanged(any()) + } + + @Test + fun `an adapter OFF broadcast is forwarded to the patch`() { + start() + + sendAdapterState(BluetoothAdapter.STATE_OFF) + + val captor = argumentCaptor() + verify(carelevoPatch, atLeastOnce()).onBluetoothStateChanged(captor.capture()) + assertThat(captor.lastValue.isEnabled).isEqualTo(DeviceModuleState.DEVICE_STATE_OFF) + } + + @Test + fun `an adapter TURNING_OFF broadcast is forwarded to the patch`() { + start() + + sendAdapterState(BluetoothAdapter.STATE_TURNING_OFF) + + val captor = argumentCaptor() + verify(carelevoPatch, atLeastOnce()).onBluetoothStateChanged(captor.capture()) + assertThat(captor.lastValue.isEnabled).isEqualTo(DeviceModuleState.DEVICE_STATE_TUNING_OFF) + } + + @Test + fun `an out-of-range adapter state is ignored instead of crashing the receiver`() { + // codeToDeviceResult() throws on anything outside {-1, 10..13}, so the receiver MUST filter + // first — an unfiltered EXTRA_STATE would blow up inside a system broadcast. + start() + // Exactly one call so far: the startup seed. + verify(carelevoPatch, times(1)).onBluetoothStateChanged(any()) + + sendAdapterState(BluetoothAdapter.ERROR) + + verify(carelevoPatch, times(1)).onBluetoothStateChanged(any()) + } + + private fun sendAdapterState(state: Int) { + context.sendBroadcast( + Intent(BluetoothAdapter.ACTION_STATE_CHANGED).putExtra(BluetoothAdapter.EXTRA_STATE, state) + ) + Shadows.shadowOf(Looper.getMainLooper()).idle() + } + + // ---- preference observers ------------------------------------------------------------------- + + @Test + fun `a max bolus change is pushed to the patch through the command queue`() { + // Queued, not fire-and-forget: the pump idle-disconnects between commands, so only the queue's + // connect-before-execute gets the write onto the patch. + start() + awaitCollector(maxBolusFlow) + + maxBolusFlow.value = 9.0 + + verifyBlocking(commandQueue, timeout(2_000)) { customCommand(any()) } + // The value comes from preferences.get(), not from the emitted value. + assertThat(capturedCommands().first().maxBolusDose).isWithin(0.001).of(7.5) + } + + @Test + fun `an expiry threshold change is pushed to the patch with the stored hours`() { + whenever(sp.getInt(eq(CarelevoIntPreferenceKey.CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS.key), any())).thenReturn(120) + start() + awaitCollector(expiryFlow) + + expiryFlow.value = 120 + + verifyBlocking(commandQueue, timeout(2_000)) { customCommand(any()) } + assertThat(capturedCommands().first().hours).isEqualTo(120) + } + + @Test + fun `a buzzer change is pushed to the patch with the stored flag`() { + whenever(sp.getBoolean(eq(CarelevoBooleanPreferenceKey.CARELEVO_BUZZER_REMINDER.key), any())).thenReturn(true) + start() + awaitCollector(buzzerFlow) + + buzzerFlow.value = true + + verifyBlocking(commandQueue, timeout(2_000)) { customCommand(any()) } + assertThat(capturedCommands().first().on).isTrue() + } + + @Test + fun `a zero low-insulin reminder is not enqueued but a real one is`() { + // Zero = reminder off. Enqueuing it would wake the patch over BLE just to no-op. + val key = CarelevoIntPreferenceKey.CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS.key + whenever(sp.getInt(eq(key), any())).thenReturn(0) + start() + awaitCollector(lowInsulinFlow) + + lowInsulinFlow.value = 0 + // Barrier: once the collector has read sp for the 0-value emission, its skip decision is made. + // Only then re-stub + emit again, so the two emissions cannot be conflated into one. + verify(sp, timeout(2_000)).getInt(eq(key), eq(0)) + + whenever(sp.getInt(eq(key), any())).thenReturn(25) + lowInsulinFlow.value = 25 + + // Same collector, ordered: observing the second emission's command proves the first is done. + verifyBlocking(commandQueue, timeout(2_000)) { customCommand(any()) } + val pushed = capturedCommands() + assertThat(pushed).hasSize(1) + assertThat(pushed.first().hours).isEqualTo(25) + } + + // ---- deferred settings-sync recovery -------------------------------------------------------- + + @Test + fun `a pending max-bolus sync is pushed once the patch is booted and idle`() { + start() + + userSettingSubject.onNext( + Optional.of(CarelevoUserSettingInfoDomainModel(maxBolusDose = 12.0, needMaxBolusDoseSyncPatch = true)) + ) + + verifyBlocking(commandQueue, timeout(2_000)) { customCommand(any()) } + assertThat(capturedCommands().first().maxBolusDose).isWithin(0.001).of(12.0) + } + + @Test + fun `a pending max-bolus sync with no stored dose falls back to zero`() { + start() + + userSettingSubject.onNext( + Optional.of(CarelevoUserSettingInfoDomainModel(maxBolusDose = null, needMaxBolusDoseSyncPatch = true)) + ) + + verifyBlocking(commandQueue, timeout(2_000)) { customCommand(any()) } + assertThat(capturedCommands().first().maxBolusDose).isWithin(0.001).of(0.0) + } + + @Test + fun `a pending low-insulin sync is pushed once the patch is booted`() { + start() + + userSettingSubject.onNext( + Optional.of(CarelevoUserSettingInfoDomainModel(lowInsulinNoticeAmount = 22, needLowInsulinNoticeAmountSyncPatch = true)) + ) + + verifyBlocking(commandQueue, timeout(2_000)) { customCommand(any()) } + assertThat(capturedCommands().first().hours).isEqualTo(22) + } + + @Test + fun `nothing is pushed while the patch is not booted and it flushes once it boots`() { + start() + patchStateSubject.onNext(Optional.of(PatchState.NotConnectedBooted)) + + val pending = CarelevoUserSettingInfoDomainModel(maxBolusDose = 12.0, needMaxBolusDoseSyncPatch = true) + userSettingSubject.onNext(Optional.of(pending)) + // Not booted -> the combiner yields the empty need, so no CmdUpdateMaxBolus can ever be produced. + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + + verifyBlocking(commandQueue, timeout(2_000)) { customCommand(any()) } + // Exactly one: the not-booted pass contributed nothing. + assertThat(capturedCommands()).hasSize(1) + } + + @Test + fun `a pending max-bolus sync is held back while an immediate bolus is running`() { + // Max bolus is a device safety cap — re-pushing it mid-bolus would force a spurious reconnect. + start() + infusionInfoSubject.onNext( + Optional.of( + CarelevoInfusionInfoDomainModel( + immeBolusInfusionInfo = CarelevoImmeBolusInfusionInfoDomainModel( + infusionId = "imme-1", + address = "AA:BB:CC:DD:EE:FF", + mode = 3, + volume = 1.0, + infusionDurationSeconds = 30 + ) + ) + ) + ) + + userSettingSubject.onNext( + Optional.of(CarelevoUserSettingInfoDomainModel(maxBolusDose = 12.0, needMaxBolusDoseSyncPatch = true)) + ) + // Bolus finished -> the same pending flag must now flush. + infusionInfoSubject.onNext(Optional.of(CarelevoInfusionInfoDomainModel())) + + verifyBlocking(commandQueue, timeout(2_000)) { customCommand(any()) } + assertThat(capturedCommands()).hasSize(1) + } + + @Test + fun `a pending max-bolus sync is held back while an extended bolus is running`() { + // BOTH channels must be idle: an `||` here (the old bug) read as idle during a single-channel + // bolus and triggered a mid-bolus reconnect. + start() + infusionInfoSubject.onNext( + Optional.of( + CarelevoInfusionInfoDomainModel( + extendBolusInfusionInfo = CarelevoExtendBolusInfusionInfoDomainModel( + infusionId = "ext-1", + address = "AA:BB:CC:DD:EE:FF", + mode = 4, + volume = 1.0, + infusionDurationMin = 30 + ) + ) + ) + ) + + userSettingSubject.onNext( + Optional.of(CarelevoUserSettingInfoDomainModel(maxBolusDose = 12.0, needMaxBolusDoseSyncPatch = true)) + ) + infusionInfoSubject.onNext(Optional.of(CarelevoInfusionInfoDomainModel())) + + verifyBlocking(commandQueue, timeout(2_000)) { customCommand(any()) } + assertThat(capturedCommands()).hasSize(1) + } + + @Test + fun `a low-insulin sync is pushed even mid-bolus - only max bolus is gated`() { + start() + infusionInfoSubject.onNext( + Optional.of( + CarelevoInfusionInfoDomainModel( + immeBolusInfusionInfo = CarelevoImmeBolusInfusionInfoDomainModel( + infusionId = "imme-1", + address = "AA:BB:CC:DD:EE:FF", + mode = 3, + volume = 1.0, + infusionDurationSeconds = 30 + ) + ) + ) + ) + + userSettingSubject.onNext( + Optional.of(CarelevoUserSettingInfoDomainModel(lowInsulinNoticeAmount = 22, needLowInsulinNoticeAmountSyncPatch = true)) + ) + + verifyBlocking(commandQueue, timeout(2_000)) { customCommand(any()) } + } + + // ---- handleAlarms --------------------------------------------------------------------------- + + @Test + fun `handleAlarms ignores an empty alarm set`() { + start() + + alarmHandler().invoke(emptyList()) + + verify(uiInteraction, never()).runAlarm(any(), any(), any()) + verify(carelevoAlarmNotifier, never()).showTopNotification(any()) + } + + @Test + fun `handleAlarms shows a top notification for non-critical notices`() { + start() + + alarmHandler().invoke(listOf(alarm(AlarmCause.ALARM_NOTICE_LGS_START))) + + verify(carelevoAlarmNotifier).showTopNotification(any()) + verify(uiInteraction, never()).runAlarm(any(), any(), any()) + } + + @Test + fun `handleAlarms escalates a critical alarm to the global alarm when the compose host is down`() { + // A critical patch alarm must NEVER depend on the user having the Carelevo screen open. + whenever(carelevoAlarmNotifier.alarmHostActive).thenReturn(false) + start() + + alarmHandler().invoke(listOf(alarm(AlarmCause.ALARM_WARNING_PUMP_CLOGGED))) + + verify(uiInteraction).runAlarm(any(), any(), eq(CoreUiR.raw.error)) + } + + @Test + fun `handleAlarms escalates a critical ALERT tier too`() { + whenever(carelevoAlarmNotifier.alarmHostActive).thenReturn(false) + start() + + alarmHandler().invoke(listOf(alarm(AlarmCause.ALARM_ALERT_OUT_OF_INSULIN))) + + verify(uiInteraction).runAlarm(any(), any(), eq(CoreUiR.raw.error)) + } + + @Test + fun `handleAlarms leaves a critical alarm to the compose host when it is mounted`() { + // The host presents the full-screen alarm and starts the sound itself; escalating too would + // double up the alarm sound. + whenever(carelevoAlarmNotifier.alarmHostActive).thenReturn(true) + start() + + alarmHandler().invoke(listOf(alarm(AlarmCause.ALARM_WARNING_PUMP_CLOGGED))) + + verify(uiInteraction, never()).runAlarm(any(), any(), any()) + } + + @Test + fun `handleAlarms does not show a top notification when a critical alarm is present`() { + whenever(carelevoAlarmNotifier.alarmHostActive).thenReturn(false) + start() + + alarmHandler().invoke( + listOf( + alarm(AlarmCause.ALARM_NOTICE_LGS_START, id = "notice"), + alarm(AlarmCause.ALARM_WARNING_PUMP_CLOGGED, id = "critical") + ) + ) + + verify(uiInteraction).runAlarm(any(), any(), any()) + verify(carelevoAlarmNotifier, never()).showTopNotification(any()) + } + + @Test + fun `handleAlarms does not re-fire the global alarm when the same set is re-emitted`() { + whenever(carelevoAlarmNotifier.alarmHostActive).thenReturn(false) + start() + val handle = alarmHandler() + val alarms = listOf(alarm(AlarmCause.ALARM_WARNING_PUMP_CLOGGED, id = "a")) + + handle.invoke(alarms) + handle.invoke(alarms) + handle.invoke(alarms) + + verify(uiInteraction, times(1)).runAlarm(any(), any(), any()) + } + + @Test + fun `handleAlarms fires once per distinct critical alarm`() { + whenever(carelevoAlarmNotifier.alarmHostActive).thenReturn(false) + start() + val handle = alarmHandler() + + handle.invoke(listOf(alarm(AlarmCause.ALARM_WARNING_PUMP_CLOGGED, id = "a"))) + handle.invoke( + listOf( + alarm(AlarmCause.ALARM_WARNING_PUMP_CLOGGED, id = "a"), + alarm(AlarmCause.ALARM_WARNING_PATCH_ERROR, id = "b") + ) + ) + + // "a" is already escalated; only the fresh "b" may ring. + verify(uiInteraction, times(2)).runAlarm(any(), any(), any()) + } + + @Test + fun `handleAlarms re-arms an alarm id after it cleared`() { + // globallyAlarmedIds is pruned to the active set on every pass, so the same id recurring after + // it was cleared is a NEW event and must ring again. + whenever(carelevoAlarmNotifier.alarmHostActive).thenReturn(false) + start() + val handle = alarmHandler() + val alarms = listOf(alarm(AlarmCause.ALARM_WARNING_PUMP_CLOGGED, id = "a")) + + handle.invoke(alarms) + handle.invoke(emptyList()) + handle.invoke(alarms) + + verify(uiInteraction, times(2)).runAlarm(any(), any(), any()) + } + + @Test + fun `handleAlarms does not re-fire after the critical set shrinks to other still-active alarms`() { + whenever(carelevoAlarmNotifier.alarmHostActive).thenReturn(false) + start() + val handle = alarmHandler() + val a = alarm(AlarmCause.ALARM_WARNING_PUMP_CLOGGED, id = "a") + val b = alarm(AlarmCause.ALARM_WARNING_PATCH_ERROR, id = "b") + + handle.invoke(listOf(a, b)) + // "b" cleared, "a" still active and still escalated -> silence. + handle.invoke(listOf(a)) + + verify(uiInteraction, times(1)).runAlarm(any(), any(), any()) + } + + // ---- foreground refresh --------------------------------------------------------------------- + + @Test + fun `a foreground transition refreshes the alarms`() { + start() + val registry = ProcessLifecycleOwner.get().lifecycle as LifecycleRegistry + + // Force a real ON_START edge regardless of the state Robolectric left the process at. + registry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + registry.handleLifecycleEvent(Lifecycle.Event.ON_START) + + verify(carelevoAlarmNotifier, atLeastOnce()).refreshAlarms() + } + + // ---- onStop --------------------------------------------------------------------------------- + + @Test + fun `onStop stops the alarm observer and clears the stored user settings`() { + start() + + runBlocking { plugin.onStop() } + started = false + + verify(carelevoAlarmNotifier).stopObserving() + verify(deleteUserSettingInfoUseCase).execute() + } + + @Test + fun `onStop cancels the preference observers so later changes are not enqueued`() { + start() + awaitCollector(maxBolusFlow) + + runBlocking { plugin.onStop() } + started = false + awaitCollectorGone(maxBolusFlow) + + maxBolusFlow.value = 9.0 + + verifyBlocking(commandQueue, never()) { customCommand(any()) } + } + + @Test + fun `onStop detaches the foreground observer so it no longer refreshes alarms`() { + start() + runBlocking { plugin.onStop() } + started = false + val registry = ProcessLifecycleOwner.get().lifecycle as LifecycleRegistry + + registry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + registry.handleLifecycleEvent(Lifecycle.Event.ON_START) + + verify(carelevoAlarmNotifier, never()).refreshAlarms() + } + + @Test + fun `onStop without a preceding onStart does not throw`() { + // PluginBase launches onStart/onStop as separate unjoined coroutines, so a teardown with no + // live generation (null scope / null observer) must be a clean no-op. + runBlocking { plugin.onStop() } + + verify(carelevoAlarmNotifier).stopObserving() + } + + @Test + fun `onStop is idempotent`() { + start() + + runBlocking { plugin.onStop() } + runBlocking { plugin.onStop() } + started = false + + verify(carelevoAlarmNotifier, times(2)).stopObserving() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginMoreTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginMoreTest.kt new file mode 100644 index 000000000000..f1e8f3bc6022 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginMoreTest.kt @@ -0,0 +1,313 @@ +package app.aaps.pump.carelevo + +import app.aaps.core.data.pump.defs.PumpType +import app.aaps.core.data.pump.defs.TimeChangeType +import app.aaps.core.interfaces.pump.PumpProfile +import app.aaps.core.interfaces.queue.CustomCommand +import app.aaps.core.keys.interfaces.IntPreferenceKey +import app.aaps.pump.carelevo.command.CmdPumpResume +import app.aaps.pump.carelevo.command.CmdTimeZoneUpdate +import app.aaps.pump.carelevo.common.keys.CarelevoBooleanPreferenceKey +import app.aaps.pump.carelevo.common.keys.CarelevoIntPreferenceKey +import app.aaps.pump.carelevo.common.model.PatchState +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import java.util.Optional + +/** + * Gap-closing companion to [CarelevoPumpPluginTest] / [CarelevoPumpPluginBolusTest] / + * [CarelevoPumpPluginTempBasalTest] / [CarelevoPumpPluginStatusTest]. + * + * Covers the plugin surface those suites leave untouched: the `isConfigured` activation gate, the + * `setNewBasalProfile` state branch, the CustomCommand routing to + * [app.aaps.pump.carelevo.command.CarelevoActivationExecutor], the `_lastDataTime` bookkeeping, the + * remaining `timezoneOrDSTChanged` branches and the preference-screen definition. + * + * The lifecycle half of the plugin (onStart/onStop, the preference observers, the deferred + * settings-sync recovery and handleAlarms) needs a real Looper/ProcessLifecycleOwner and lives in + * the Robolectric suite [CarelevoPumpPluginLifecycleTest]. + */ +class CarelevoPumpPluginMoreTest : CarelevoPumpPluginTestBase() { + + private fun pumpProfile(): PumpProfile = mock().also { + whenever(it.getBasal()).thenReturn(1.0) + } + + // ---- isConfigured (activation gate) -------------------------------------------------------- + + @Test + fun `isConfigured should be true once activation persisted a patch record`() { + assertThat(plugin.isConfigured()).isTrue() + } + + @Test + fun `isConfigured should be false when no patch record exists`() { + patchInfoSubject.onNext(Optional.empty()) + + assertThat(plugin.isConfigured()).isFalse() + } + + @Test + fun `not configured should imply not initialized`() { + // Contract invariant: isInitialized() gates on the same patchInfo signal first, so + // !isConfigured() => !isInitialized() must hold by construction. + patchInfoSubject.onNext(Optional.empty()) + + assertThat(plugin.isConfigured()).isFalse() + assertThat(plugin.isInitialized()).isFalse() + } + + // ---- setNewBasalProfile --------------------------------------------------------------------- + + @Test + fun `setNewBasalProfile should store the profile without enacting when no patch is active`() { + // NotConnectedNotBooting = no patch yet: the profile is only cached for a later activation, so + // this is a deferred write (enacted=false, no PROFILE_SET_OK) that must still report success=true + // to keep the not-ready case out of the central failure alarm. + doReturn(PatchState.NotConnectedNotBooting).whenever(carelevoPatch).resolvePatchState() + val profile = pumpProfile() + + val result = runBlocking { plugin.setNewBasalProfile(profile) } + + assertThat(result.success).isTrue() + assertThat(result.enacted).isFalse() + verify(carelevoPatch).setProfile(profile) + } + + @Test + fun `setNewBasalProfile should not touch the BLE session when no patch is active`() { + doReturn(PatchState.NotConnectedNotBooting).whenever(carelevoPatch).resolvePatchState() + + runBlocking { plugin.setNewBasalProfile(pumpProfile()) } + + verifyBlocking(bleSession, never()) { runBasalProgram(any(), any(), any()) } + } + + @Test + fun `setNewBasalProfile should push the program to the patch when a patch is present`() { + val profile = pumpProfile() + + val result = runBlocking { plugin.setNewBasalProfile(profile) } + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + verifyBlocking(bleSession) { runBasalProgram(any(), any(), any()) } + verify(carelevoPatch).setProfile(profile) + } + + @Test + fun `setNewBasalProfile should fail when the basal program write is rejected`() { + whenever { bleSession.runBasalProgram(any(), any(), any()) }.thenReturn(false) + + val result = runBlocking { plugin.setNewBasalProfile(pumpProfile()) } + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + } + + @Test + fun `setNewBasalProfile should refresh lastDataTime`() { + assertThat(plugin.lastDataTime.value).isEqualTo(0L) + + runBlocking { plugin.setNewBasalProfile(pumpProfile()) } + + assertThat(plugin.lastDataTime.value).isGreaterThan(0L) + } + + // ---- isThisProfileSet ----------------------------------------------------------------------- + + @Test + fun `isThisProfileSet should delegate to the patch profile comparison`() { + val profile = pumpProfile() + whenever(carelevoPatch.checkIsSameProfile(profile)).thenReturn(true) + + assertThat(plugin.isThisProfileSet(profile)).isTrue() + verify(carelevoPatch).checkIsSameProfile(profile) + } + + @Test + fun `isThisProfileSet should return false when the patch reports a different profile`() { + val profile = pumpProfile() + whenever(carelevoPatch.checkIsSameProfile(profile)).thenReturn(false) + + assertThat(plugin.isThisProfileSet(profile)).isFalse() + } + + // ---- executeCustomCommand (queue routing) --------------------------------------------------- + + @Test + fun `executeCustomCommand should return the activation executor result`() { + val command = CmdPumpResume() + val expected = fakePumpEnactResult() + whenever(activationExecutor.execute(command)).thenReturn(expected) + + assertThat(plugin.executeCustomCommand(command)).isSameInstanceAs(expected) + } + + @Test + fun `executeCustomCommand should forward an unknown command and return the executor null`() { + // Unknown commands are the executor's call, not the plugin's — the plugin is a pure router. + val unknown = object : CustomCommand { + override val statusDescription: String = "UNKNOWN" + } + whenever(activationExecutor.execute(unknown)).thenReturn(null) + + assertThat(plugin.executeCustomCommand(unknown)).isNull() + verify(activationExecutor).execute(unknown) + } + + // ---- basal rate / bolus state delegation ---------------------------------------------------- + + @Test + fun `baseBasalRate should return the current profile basal`() { + assertThat(plugin.baseBasalRate.cU).isWithin(0.001).of(1.0) + } + + @Test + fun `lastBolusTime and lastBolusAmount should delegate to the bolus coordinator defaults`() { + assertThat(plugin.lastBolusTime.value).isNull() + assertThat(plugin.lastBolusAmount.value).isNull() + } + + @Test + fun `stopBolusDelivering should be a no-op when no patch address is stored`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + + plugin.stopBolusDelivering() + + // Delegated to the bolus coordinator, which bails out before opening a session. + verify(carelevoPatch).getPatchInfoAddress() + } + + // ---- lastDataTime --------------------------------------------------------------------------- + + @Test + fun `lastDataTime should start at zero`() { + assertThat(plugin.lastDataTime.value).isEqualTo(0L) + } + + @Test + fun `getPumpStatus should refresh lastDataTime after a successful read`() { + runBlocking { plugin.getPumpStatus("test") } + + assertThat(plugin.lastDataTime.value).isGreaterThan(0L) + } + + @Test + fun `getPumpStatus should leave lastDataTime untouched when the read fails`() { + whenever { bleSession.readInfusionInfo(any()) }.thenAnswer { throw IllegalStateException("unreachable") } + + runBlocking { plugin.getPumpStatus("test") } + + assertThat(plugin.lastDataTime.value).isEqualTo(0L) + } + + // ---- timezoneOrDSTChanged ------------------------------------------------------------------- + + @Test + fun `timezoneOrDSTChanged should skip the queue when no patch is active`() { + patchInfoSubject.onNext(Optional.empty()) + + runBlocking { plugin.timezoneOrDSTChanged(TimeChangeType.TimezoneChanged) } + + verifyBlocking(commandQueue, never()) { customCommand(any()) } + } + + @Test + fun `timezoneOrDSTChanged should carry the remaining insulin to the patch clock update`() { + patchInfoSubject.onNext(Optional.of(samplePatchInfo(insulinRemain = 42.7))) + whenever { commandQueue.customCommand(any()) }.thenReturn(fakePumpEnactResult()) + + runBlocking { plugin.timezoneOrDSTChanged(TimeChangeType.TimezoneChanged) } + + val captor = argumentCaptor() + verifyBlocking(commandQueue) { customCommand(captor.capture()) } + assertThat((captor.firstValue as CmdTimeZoneUpdate).insulinAmount).isEqualTo(42) + } + + @Test + fun `timezoneOrDSTChanged should still push with zero insulin when the remaining amount is unknown`() { + // Patch present but its reservoir was not read yet (e.g. before the first status read after a + // reconnect): the clock update must still go out rather than being dropped. + patchInfoSubject.onNext(Optional.of(samplePatchInfo().copy(insulinRemain = null))) + whenever { commandQueue.customCommand(any()) }.thenReturn(fakePumpEnactResult()) + + runBlocking { plugin.timezoneOrDSTChanged(TimeChangeType.TimezoneChanged) } + + val captor = argumentCaptor() + verifyBlocking(commandQueue) { customCommand(captor.capture()) } + assertThat((captor.firstValue as CmdTimeZoneUpdate).insulinAmount).isEqualTo(0) + } + + @Test + fun `timezoneOrDSTChanged should refresh lastDataTime only when the queue reports success`() { + whenever { commandQueue.customCommand(any()) }.thenReturn(fakePumpEnactResult(success = true)) + + runBlocking { plugin.timezoneOrDSTChanged(TimeChangeType.TimezoneChanged) } + + assertThat(plugin.lastDataTime.value).isGreaterThan(0L) + } + + @Test + fun `timezoneOrDSTChanged should leave lastDataTime untouched when the queue reports failure`() { + whenever { commandQueue.customCommand(any()) }.thenReturn(fakePumpEnactResult(success = false)) + + runBlocking { plugin.timezoneOrDSTChanged(TimeChangeType.TimezoneChanged) } + + assertThat(plugin.lastDataTime.value).isEqualTo(0L) + } + + // ---- static descriptors --------------------------------------------------------------------- + + @Test + fun `pumpDescription should be filled for the Carelevo pump type`() { + assertThat(plugin.pumpDescription.pumpType).isEqualTo(PumpType.CAREMEDI_CARELEVO) + } + + @Test + fun `pumpDescription should be the same cached instance on every read`() { + assertThat(plugin.pumpDescription).isSameInstanceAs(plugin.pumpDescription) + } + + @Test + fun `serialNumber should be empty when no patch record exists`() { + patchInfoSubject.onNext(Optional.empty()) + + assertThat(plugin.serialNumber()).isEmpty() + } + + @Test + fun `getPreferenceScreenContent should expose the three carelevo patch settings`() { + val screen = plugin.getPreferenceScreenContent() + + assertThat(screen.key).isEqualTo("carelevo_settings") + assertThat(screen.titleResId).isEqualTo(R.string.carelevo) + assertThat(screen.items).hasSize(3) + assertThat((screen.items[0] as IntPreferenceKey).key) + .isEqualTo(CarelevoIntPreferenceKey.CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS.key) + assertThat((screen.items[1] as IntPreferenceKey).key) + .isEqualTo(CarelevoIntPreferenceKey.CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS.key) + assertThat(screen.items[2]).isEqualTo(CarelevoBooleanPreferenceKey.CARELEVO_BUZZER_REMINDER) + } + + @Test + fun `getPreferenceScreenContent should attach the plugin icon`() { + assertThat(plugin.getPreferenceScreenContent().icon).isNotNull() + } + + @Test + fun `isSuspended should be false when no patch record exists`() { + patchInfoSubject.onNext(Optional.empty()) + + assertThat(plugin.isSuspended()).isFalse() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginStatusTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginStatusTest.kt new file mode 100644 index 000000000000..724c3d205c41 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginStatusTest.kt @@ -0,0 +1,82 @@ +package app.aaps.pump.carelevo + +import app.aaps.core.data.pump.defs.TimeChangeType +import app.aaps.pump.carelevo.command.CmdTimeZoneUpdate +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever + +class CarelevoPumpPluginStatusTest : CarelevoPumpPluginTestBase() { + + @Test + fun `connect should not throw and should keep plugin usable`() { + plugin.connect("test") + + assertThat(plugin).isNotNull() + } + + @Test + fun `disconnect should not throw`() { + plugin.disconnect("test") + + assertThat(plugin).isNotNull() + } + + @Test + fun `stopConnecting should not throw`() { + plugin.stopConnecting() + + assertThat(plugin).isNotNull() + } + + @Test + fun `getPumpStatus should skip the read when no patch address is stored`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + + runBlocking { plugin.getPumpStatus("test") } + + verifyBlocking(bleSession, never()) { readInfusionInfo(any()) } + } + + @Test + fun `getPumpStatus should swallow a session failure without throwing`() { + whenever { bleSession.readInfusionInfo(any()) }.thenAnswer { throw IllegalStateException("unreachable") } + + runBlocking { plugin.getPumpStatus("test") } + + verify(carelevoPatch, never()).applyInfusionInfoReport(any(), any(), any(), any(), any(), any()) + } + + @Test + fun `getPumpStatus should read infusion info over the session and persist it`() { + runBlocking { plugin.getPumpStatus("test") } + + verifyBlocking(bleSession) { readInfusionInfo(any()) } + verify(carelevoPatch).applyInfusionInfoReport( + runningMinutes = 100, + remains = 60.0, + infusedTotalBasalAmount = 1.0, + infusedTotalBolusAmount = 2.0, + pumpStateRaw = 0, + modeRaw = 1 + ) + } + + @Test + fun `timezoneOrDSTChanged should route a CmdTimeZoneUpdate through the command queue`() { + // Now managed by the queue (connect-before-execute) instead of a direct BLE write, so a resting + // pump reconnects first. The executor runs the timezone use case on the queue worker thread. + runBlocking { + whenever(commandQueue.customCommand(any())).thenReturn(fakePumpEnactResult()) + + plugin.timezoneOrDSTChanged(TimeChangeType.TimezoneChanged) + + verify(commandQueue).customCommand(any()) + } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginTempBasalTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginTempBasalTest.kt new file mode 100644 index 000000000000..2358b8191771 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginTempBasalTest.kt @@ -0,0 +1,137 @@ +package app.aaps.pump.carelevo + +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.pump.carelevo.ble.commands.SimpleResultResponse +import app.aaps.pump.carelevo.ble.commands.TempBasalCancelCommand +import app.aaps.pump.carelevo.ble.commands.TempBasalCommand +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.isA +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever + +class CarelevoPumpPluginTempBasalTest : CarelevoPumpPluginTestBase() { + + @Test + fun `setTempBasalAbsolute should return not enacted when bluetooth is disabled`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(false) + + val result = runBlocking { plugin.setTempBasalAbsolute(1.2, 30, false, PumpSync.TemporaryBasalType.NORMAL) } + + assertThat(result.enacted).isFalse() + } + + @Test + fun `setTempBasalAbsolute should return not enacted when no patch address is stored`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + + val result = runBlocking { plugin.setTempBasalAbsolute(1.2, 30, false, PumpSync.TemporaryBasalType.NORMAL) } + + assertThat(result.enacted).isFalse() + } + + @Test + fun `setTempBasalAbsolute should succeed on success response`() { + val result = runBlocking { plugin.setTempBasalAbsolute(1.2, 30, false, PumpSync.TemporaryBasalType.NORMAL) } + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + assertThat(result.absolute).isWithin(0.001).of(1.2) + } + + @Test + fun `setTempBasalAbsolute should fail when the pump rejects the command`() { + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenReturn(SimpleResultResponse(1)) + + val result = runBlocking { plugin.setTempBasalAbsolute(1.2, 30, false, PumpSync.TemporaryBasalType.NORMAL) } + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + } + + @Test + fun `setTempBasalAbsolute should require a positive duration`() { + assertThrows(IllegalArgumentException::class.java) { + runBlocking { plugin.setTempBasalAbsolute(1.2, 0, false, PumpSync.TemporaryBasalType.NORMAL) } + } + } + + @Test + fun `setTempBasalAbsolute should record in pumpSync even when the local persist fails`() { + // Pump ACKed — the TBR IS running on the patch; a failed local persist must not keep it + // out of pumpSync or basal IOB modeling diverges for the whole TBR duration. + whenever(startTempBasalInfusionUseCase.persistTempBasalStarted(any())).thenReturn(false) + + val result = runBlocking { plugin.setTempBasalAbsolute(1.2, 30, false, PumpSync.TemporaryBasalType.NORMAL) } + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + verifyBlocking(pumpSync) { syncTemporaryBasalWithPumpId(any(), any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `cancelTempBasal should record the stop in pumpSync even when the local persist fails`() { + whenever(cancelTempBasalInfusionUseCase.persistTempBasalCancelled()).thenReturn(false) + + val result = runBlocking { plugin.cancelTempBasal(false) } + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + verifyBlocking(pumpSync) { syncStopTemporaryBasalWithPumpId(any(), any(), any(), any(), any()) } + } + + @Test + fun `setTempBasalPercent should succeed on success response`() { + val result = runBlocking { plugin.setTempBasalPercent(150, 30, false, PumpSync.TemporaryBasalType.NORMAL) } + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + assertThat(result.percent).isEqualTo(150) + } + + @Test + fun `setTempBasalPercent should fail when the session throws`() { + // A BLE error must be caught and mapped to a failed PumpEnactResult, not propagated out of the + // plugin — an exception escaping here would crash the command queue. + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenAnswer { throw IllegalStateException("timeout") } + + val result = runBlocking { plugin.setTempBasalPercent(150, 30, false, PumpSync.TemporaryBasalType.NORMAL) } + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + } + + @Test + fun `cancelTempBasal should return not enacted when no patch address is stored`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + + val result = runBlocking { plugin.cancelTempBasal(false) } + + assertThat(result.enacted).isFalse() + } + + @Test + fun `cancelTempBasal should succeed on success response`() { + val result = runBlocking { plugin.cancelTempBasal(false) } + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + assertThat(result.isTempCancel).isTrue() + } + + @Test + fun `cancelTempBasal should return success false and enacted false on timeout`() { + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenAnswer { throw IllegalStateException("timeout") } + + val result = runBlocking { plugin.cancelTempBasal(false) } + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginTest.kt new file mode 100644 index 000000000000..664bacdc0f00 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginTest.kt @@ -0,0 +1,151 @@ +package app.aaps.pump.carelevo + +import app.aaps.core.data.plugin.PluginType +import app.aaps.core.data.pump.defs.ManufacturerType +import app.aaps.core.data.pump.defs.PumpType +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.mockito.kotlin.whenever +import java.util.Optional + +class CarelevoPumpPluginTest : CarelevoPumpPluginTestBase() { + + @Test + fun `manufacturer should return Carelevo`() { + assertThat(plugin.manufacturer()).isEqualTo(ManufacturerType.CareMedi) + } + + @Test + fun `model should return CARELEVO`() { + assertThat(plugin.model()).isEqualTo(PumpType.CAREMEDI_CARELEVO) + } + + @Test + fun `serialNumber should return manufacture number`() { + patchInfoSubject.onNext(Optional.of(samplePatchInfo(manufactureNumber = "SN-1234"))) + + assertThat(plugin.serialNumber()).isEqualTo("SN-1234") + } + + @Test + fun `isInitialized should return false when patch address is missing`() { + patchInfoSubject.onNext(Optional.empty()) + + assertThat(plugin.isInitialized()).isFalse() + } + + @Test + fun `isInitialized should stay true when BLE is disconnected`() { + // Activation-based (like Omnipod Dash / Medtrum): per-op sessions mean there is no resting + // link, so a down link must NOT report the pump as un-initialized (that would abort the loop's + // TBR/SMB enact before a command can be queued). + btStateSubject.onNext(Optional.empty()) + + assertThat(plugin.isInitialized()).isTrue() + } + + @Test + fun `isInitialized should return false when operational state is missing`() { + patchInfoSubject.onNext(Optional.of(samplePatchInfo().copy(mode = null, runningMinutes = null, pumpState = null))) + + assertThat(plugin.isInitialized()).isFalse() + } + + @Test + fun `isInitialized should return true when patch is paired and operational state exists`() { + assertThat(plugin.isInitialized()).isTrue() + } + + @Test + fun `isConnected should return true when patch address is missing`() { + patchInfoSubject.onNext(Optional.empty()) + + assertThat(plugin.isConnected()).isTrue() + } + + @Test + fun `isConnected reflects the held link once the patch is activated`() { + patchInfoSubject.onNext(Optional.of(samplePatchInfo(address = "11:22:33:44:55:66"))) + + // Post-activation the queue owns a real link, so isConnected mirrors the held-link state (no + // longer hardcoded true): down → false so connect-before-execute dials first, up → true. + whenever(bleSession.connected).thenReturn(MutableStateFlow(false)) + assertThat(plugin.isConnected()).isFalse() + + whenever(bleSession.connected).thenReturn(MutableStateFlow(true)) + assertThat(plugin.isConnected()).isTrue() + } + + @Test + fun `isSuspended should reflect patch isStopped flag not the BLE link state`() { + // Real delivery-suspend (pump stopped by the user), independent of connection: a normal idle + // disconnect must NOT read as suspended (that surfaced as a false error/suspended icon before). + patchInfoSubject.onNext(Optional.of(samplePatchInfo().copy(isStopped = true))) + assertThat(plugin.isSuspended()).isTrue() + + patchInfoSubject.onNext(Optional.of(samplePatchInfo().copy(isStopped = false))) + assertThat(plugin.isSuspended()).isFalse() + } + + @Test + fun `isBusy should always return false`() { + assertThat(plugin.isBusy()).isFalse() + } + + @Test + fun `isConnecting delegates to the session`() { + whenever(bleSession.isConnecting).thenReturn(MutableStateFlow(true)) + assertThat(plugin.isConnecting()).isTrue() + } + + @Test + fun `isHandshakeInProgress should always return false`() { + assertThat(plugin.isHandshakeInProgress()).isFalse() + } + + @Test + fun `baseBasalRate should return zero when profile is missing`() { + profileSubject.onNext(Optional.empty()) + + assertThat(plugin.baseBasalRate.cU).isWithin(0.001).of(0.0) + } + + @Test + fun `reservoirLevel should default to zero before observers update state`() { + patchInfoSubject.onNext(Optional.of(samplePatchInfo(insulinRemain = 42.5))) + + assertThat(plugin.reservoirLevel.value.cU).isWithin(0.001).of(0.0) + } + + @Test + fun `batteryLevel should default to null before observers update state`() { + assertThat(plugin.batteryLevel.value).isNull() + } + + @Test + fun `isFakingTempsByExtendedBoluses should return false`() { + assertThat(plugin.isFakingTempsByExtendedBoluses).isFalse() + } + + @Test + fun `canHandleDST should return false`() { + assertThat(plugin.canHandleDST()).isFalse() + } + + @Test + fun `loadTDDs should return result object`() { + assertThat(runBlocking { plugin.loadTDDs() }).isNotNull() + } + + @Test + fun `pumpDescription should be initialized`() { + assertThat(plugin.pumpDescription).isNotNull() + } + + @Test + fun `plugin type should be PUMP`() { + assertThat(plugin.getType()).isEqualTo(PluginType.PUMP) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginTestBase.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginTestBase.kt new file mode 100644 index 000000000000..982b889f3116 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/CarelevoPumpPluginTestBase.kt @@ -0,0 +1,326 @@ +package app.aaps.pump.carelevo + +import android.content.Context +import app.aaps.core.interfaces.configuration.Config +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.profile.Profile +import app.aaps.core.interfaces.profile.ProfileFunction +import app.aaps.core.interfaces.protection.ProtectionCheck +import app.aaps.core.interfaces.pump.BlePreCheck +import app.aaps.core.interfaces.pump.BolusProgressData +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.core.interfaces.ui.IconsProvider +import app.aaps.core.interfaces.ui.UiInteraction +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.interfaces.utils.fabric.FabricPrivacy +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.pump.carelevo.ble.CarelevoBleSession +import app.aaps.pump.carelevo.ble.commands.BolusCancelCommand +import app.aaps.pump.carelevo.ble.commands.BolusCancelResponse +import app.aaps.pump.carelevo.ble.commands.ExtendBolusCancelCommand +import app.aaps.pump.carelevo.ble.commands.ExtendBolusCancelResponse +import app.aaps.pump.carelevo.ble.commands.ExtendBolusCommand +import app.aaps.pump.carelevo.ble.commands.ExtendBolusResponse +import app.aaps.pump.carelevo.ble.commands.ImmediateBolusCommand +import app.aaps.pump.carelevo.ble.commands.ImmediateBolusResponse +import app.aaps.pump.carelevo.ble.commands.InfusionInfoResponse +import app.aaps.pump.carelevo.ble.commands.SimpleResultResponse +import app.aaps.pump.carelevo.ble.commands.TempBasalCancelCommand +import app.aaps.pump.carelevo.ble.commands.TempBasalCommand +import app.aaps.pump.carelevo.ble.data.BleState +import app.aaps.pump.carelevo.ble.data.BondingState +import app.aaps.pump.carelevo.ble.data.DeviceModuleState +import app.aaps.pump.carelevo.ble.data.NotificationState +import app.aaps.pump.carelevo.ble.data.PeripheralConnectionState +import app.aaps.pump.carelevo.ble.data.ServiceDiscoverState +import app.aaps.pump.carelevo.command.CarelevoActivationExecutor +import app.aaps.pump.carelevo.common.CarelevoAlarmNotifier +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.common.model.PatchState +import app.aaps.pump.carelevo.coordinator.CarelevoBasalProfileUpdateCoordinator +import app.aaps.pump.carelevo.coordinator.CarelevoBolusCoordinator +import app.aaps.pump.carelevo.coordinator.CarelevoConnectionCoordinator +import app.aaps.pump.carelevo.coordinator.CarelevoSettingsCoordinator +import app.aaps.pump.carelevo.coordinator.CarelevoTempBasalCoordinator +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.model.result.ResultSuccess +import app.aaps.pump.carelevo.domain.usecase.basal.CarelevoCancelTempBasalInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.basal.CarelevoSetBasalProgramUseCase +import app.aaps.pump.carelevo.domain.usecase.basal.CarelevoStartTempBasalInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoCancelExtendBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoCancelImmeBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoFinishImmeBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoStartExtendBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoStartImmeBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoDeleteUserSettingInfoUseCase +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.BehaviorSubject +import org.joda.time.DateTime +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.isA +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.Optional +import javax.inject.Provider + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +abstract class CarelevoPumpPluginTestBase { + + @Mock lateinit var aapsLogger: AAPSLogger + @Mock lateinit var rh: ResourceHelper + @Mock lateinit var preferences: Preferences + @Mock lateinit var commandQueue: CommandQueue + @Mock lateinit var aapsSchedulers: AapsSchedulers + @Mock lateinit var dateUtil: DateUtil + @Mock lateinit var pumpSync: PumpSync + @Mock lateinit var sp: SP + + @Mock lateinit var fabricPrivacy: FabricPrivacy + + @Mock lateinit var protectionCheck: ProtectionCheck + @Mock lateinit var profileFunction: ProfileFunction + @Mock lateinit var blePreCheck: BlePreCheck + @Mock lateinit var iconsProvider: IconsProvider + @Mock lateinit var config: Config + @Mock lateinit var context: Context + @Mock lateinit var bolusProgressData: BolusProgressData + + @Mock lateinit var carelevoPatch: CarelevoPatch + @Mock lateinit var bleSession: CarelevoBleSession + @Mock lateinit var activationExecutor: CarelevoActivationExecutor + + @Mock lateinit var setBasalProgramUseCase: CarelevoSetBasalProgramUseCase + @Mock lateinit var startTempBasalInfusionUseCase: CarelevoStartTempBasalInfusionUseCase + @Mock lateinit var cancelTempBasalInfusionUseCase: CarelevoCancelTempBasalInfusionUseCase + @Mock lateinit var startImmeBolusInfusionUseCase: CarelevoStartImmeBolusInfusionUseCase + @Mock lateinit var startExtendBolusInfusionUseCase: CarelevoStartExtendBolusInfusionUseCase + @Mock lateinit var cancelImmeBolusInfusionUseCase: CarelevoCancelImmeBolusInfusionUseCase + @Mock lateinit var cancelExtendBolusInfusionUseCase: CarelevoCancelExtendBolusInfusionUseCase + @Mock lateinit var finishImmeBolusInfusionUseCase: CarelevoFinishImmeBolusInfusionUseCase + + @Mock lateinit var deleteUserSettingInfoUseCase: CarelevoDeleteUserSettingInfoUseCase + + @Mock lateinit var carelevoAlarmNotifier: CarelevoAlarmNotifier + @Mock lateinit var uiInteraction: UiInteraction + + protected lateinit var plugin: CarelevoPumpPlugin + protected lateinit var testProfile: Profile + + protected lateinit var patchInfoSubject: BehaviorSubject> + protected lateinit var infusionInfoSubject: BehaviorSubject> + protected lateinit var profileSubject: BehaviorSubject> + protected lateinit var patchStateSubject: BehaviorSubject> + protected lateinit var btStateSubject: BehaviorSubject> + + @BeforeEach + fun setupCarelevoPlugin() { + whenever(aapsSchedulers.io).thenReturn(Schedulers.trampoline()) + whenever(aapsSchedulers.main).thenReturn(Schedulers.trampoline()) + whenever(aapsSchedulers.cpu).thenReturn(Schedulers.trampoline()) + whenever(aapsSchedulers.newThread).thenReturn(Schedulers.trampoline()) + + whenever(dateUtil.now()).thenReturn(System.currentTimeMillis()) + whenever(rh.gs(any())).thenReturn("Mocked") + + testProfile = mock() + whenever(testProfile.getBasal()).thenReturn(1.0) + + patchInfoSubject = BehaviorSubject.createDefault(Optional.of(samplePatchInfo())) + infusionInfoSubject = BehaviorSubject.createDefault(Optional.of(CarelevoInfusionInfoDomainModel())) + profileSubject = BehaviorSubject.createDefault(Optional.of(testProfile)) + patchStateSubject = BehaviorSubject.createDefault(Optional.of(PatchState.ConnectedBooted)) + btStateSubject = BehaviorSubject.createDefault(Optional.of(connectedBleState())) + whenever(carelevoPatch.patchInfo).thenReturn(patchInfoSubject) + whenever(carelevoPatch.infusionInfo).thenReturn(infusionInfoSubject) + whenever(carelevoPatch.profile).thenReturn(profileSubject) + whenever(carelevoPatch.patchState).thenReturn(patchStateSubject) + whenever(carelevoPatch.btState).thenReturn(btStateSubject) + doReturn(PatchState.ConnectedBooted).whenever(carelevoPatch).resolvePatchState() + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + + whenever(finishImmeBolusInfusionUseCase.execute()).thenReturn(Single.just(ResponseResult.Success(ResultSuccess))) + + // The coordinators/plugin run everything over the gateway/session; stub the happy path so the + // plugin tests exercise the success flows. + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn("AA:BB:CC:DD:EE:FF") + whenever { bleSession.runSingle(any(), isA(), any()) }.thenReturn(SimpleResultResponse(0)) + whenever { bleSession.runSingle(any(), isA(), any()) }.thenReturn(SimpleResultResponse(0)) + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenReturn(ImmediateBolusResponse(actionId = 1, resultCode = 0, expectedCompletionSeconds = 1, remainingReservoirUnits = 60.0)) + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenReturn(BolusCancelResponse(resultCode = 0, infusedAmount = 0.0)) + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenReturn(ExtendBolusResponse(resultCode = 0, expectedTimeSeconds = 60)) + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenReturn(ExtendBolusCancelResponse(resultCode = 0, infusedAmount = 0.0)) + whenever { bleSession.runBasalProgram(any(), any(), any()) }.thenReturn(true) + whenever(startTempBasalInfusionUseCase.persistTempBasalStarted(any())).thenReturn(true) + whenever(cancelTempBasalInfusionUseCase.persistTempBasalCancelled()).thenReturn(true) + whenever(startImmeBolusInfusionUseCase.persistImmeBolusStarted(any(), any(), any())).thenReturn(true) + whenever(cancelImmeBolusInfusionUseCase.persistImmeBolusCancelled()).thenReturn(true) + whenever(startExtendBolusInfusionUseCase.persistExtendBolusStarted(any(), any(), any())).thenReturn(true) + whenever(cancelExtendBolusInfusionUseCase.persistExtendBolusCancelled()).thenReturn(true) + whenever(setBasalProgramUseCase.buildBasalProgramPlan(any())).thenReturn( + CarelevoSetBasalProgramUseCase.BasalProgramPlan(programs = List(3) { List(8) { 1.0 } }, segments = emptyList()) + ) + whenever(setBasalProgramUseCase.persistBasalProgram(any())).thenReturn(true) + + val pumpEnactResultProvider = Provider { FakePumpEnactResult() } + val basalProfileUpdateCoordinator = CarelevoBasalProfileUpdateCoordinator( + aapsLogger = aapsLogger, + rh = rh, + pumpEnactResultProvider = pumpEnactResultProvider, + carelevoPatch = carelevoPatch, + bleSession = bleSession, + setBasalProgramUseCase = setBasalProgramUseCase + ) + val bolusCoordinator = CarelevoBolusCoordinator( + aapsLogger = aapsLogger, + rh = rh, + dateUtil = dateUtil, + bolusProgressData = bolusProgressData, + pumpSync = pumpSync, + aapsSchedulers = aapsSchedulers, + pumpEnactResultProvider = pumpEnactResultProvider, + carelevoPatch = carelevoPatch, + bleSession = bleSession, + startImmeBolusInfusionUseCase = startImmeBolusInfusionUseCase, + finishImmeBolusInfusionUseCase = finishImmeBolusInfusionUseCase, + cancelImmeBolusInfusionUseCase = cancelImmeBolusInfusionUseCase, + startExtendBolusInfusionUseCase = startExtendBolusInfusionUseCase, + cancelExtendBolusInfusionUseCase = cancelExtendBolusInfusionUseCase + ) + val tempBasalCoordinator = CarelevoTempBasalCoordinator( + aapsLogger = aapsLogger, + dateUtil = dateUtil, + pumpSync = pumpSync, + pumpEnactResultProvider = pumpEnactResultProvider, + carelevoPatch = carelevoPatch, + bleSession = bleSession, + startTempBasalInfusionUseCase = startTempBasalInfusionUseCase, + cancelTempBasalInfusionUseCase = cancelTempBasalInfusionUseCase + ) + val connectionCoordinator = CarelevoConnectionCoordinator( + aapsLogger = aapsLogger, + carelevoPatch = carelevoPatch, + bleSession = bleSession + ) + val settingsCoordinator = CarelevoSettingsCoordinator( + aapsLogger = aapsLogger, + aapsSchedulers = aapsSchedulers, + deleteUserSettingInfoUseCase = deleteUserSettingInfoUseCase + ) + + plugin = CarelevoPumpPlugin( + aapsLogger = aapsLogger, + rh = rh, + preferences = preferences, + commandQueue = commandQueue, + aapsSchedulers = aapsSchedulers, + sp = sp, + fabricPrivacy = fabricPrivacy, + profileFunction = profileFunction, + context = context, + protectionCheck = protectionCheck, + blePreCheck = blePreCheck, + iconsProvider = iconsProvider, + config = config, + uiInteraction = uiInteraction, + pumpEnactResultProvider = pumpEnactResultProvider, + carelevoPatch = carelevoPatch, + carelevoAlarmNotifier = carelevoAlarmNotifier, + basalProfileUpdateCoordinator = basalProfileUpdateCoordinator, + bolusCoordinator = bolusCoordinator, + tempBasalCoordinator = tempBasalCoordinator, + connectionCoordinator = connectionCoordinator, + settingsCoordinator = settingsCoordinator, + activationExecutor = activationExecutor + ) + plugin.bleSession = bleSession + whenever { bleSession.readInfusionInfo(any()) }.thenReturn( + InfusionInfoResponse( + subId = 0, + runningMinutes = 100, + insulinRemaining = 60.0, + infusedTotalBasalAmount = 1.0, + infusedTotalBolusAmount = 2.0, + pumpStateRaw = 0, + modeRaw = 1, + currentInfusedProgramVolume = 0.0, + realInfusedTime = 0 + ) + ) + } + + /** Fully-ready link (bonded + discovered + notifications) so `BleState.isConnected()` returns true. */ + protected fun connectedBleState(): BleState = + BleState( + isEnabled = DeviceModuleState.DEVICE_STATE_ON, + isBonded = BondingState.BOND_BONDED, + isServiceDiscovered = ServiceDiscoverState.DISCOVER_STATE_DISCOVERED, + isConnected = PeripheralConnectionState.CONN_STATE_CONNECTED, + isNotificationEnabled = NotificationState.NOTIFICATION_ENABLED + ) + + protected fun samplePatchInfo( + address: String = "AA:BB:CC:DD:EE:FF", + manufactureNumber: String = "CARELEVO-TEST-001", + insulinRemain: Double = 60.0, + bolusActionSeq: Int = 1 + ): CarelevoPatchInfoDomainModel = + CarelevoPatchInfoDomainModel( + address = address, + createdAt = DateTime.now().minusHours(1), + updatedAt = DateTime.now(), + manufactureNumber = manufactureNumber, + insulinRemain = insulinRemain, + bolusActionSeq = bolusActionSeq, + mode = 1 + ) + + /** A concrete [PumpEnactResult] for stubbing suspend queue calls (e.g. `commandQueue.customCommand`). */ + protected fun fakePumpEnactResult(success: Boolean = true): PumpEnactResult = + FakePumpEnactResult().success(success).enacted(success) + + private class FakePumpEnactResult : PumpEnactResult { + + override var success: Boolean = false + override var enacted: Boolean = false + override var comment: String = "" + override var duration: Int = -1 + override var absolute: Double = -1.0 + override var percent: Int = -1 + override var isPercent: Boolean = false + override var isTempCancel: Boolean = false + override var bolusDelivered: Double = 0.0 + override var queued: Boolean = false + + override fun success(success: Boolean): PumpEnactResult = apply { this.success = success } + override fun enacted(enacted: Boolean): PumpEnactResult = apply { this.enacted = enacted } + override fun comment(comment: String): PumpEnactResult = apply { this.comment = comment } + override fun comment(comment: Int): PumpEnactResult = apply { this.comment = comment.toString() } + override fun duration(duration: Int): PumpEnactResult = apply { this.duration = duration } + override fun absolute(absolute: Double): PumpEnactResult = apply { this.absolute = absolute } + override fun percent(percent: Int): PumpEnactResult = apply { this.percent = percent } + override fun isPercent(isPercent: Boolean): PumpEnactResult = apply { this.isPercent = isPercent } + override fun isTempCancel(isTempCancel: Boolean): PumpEnactResult = apply { this.isTempCancel = isTempCancel } + override fun bolusDelivered(bolusDelivered: Double): PumpEnactResult = apply { this.bolusDelivered = bolusDelivered } + override fun queued(queued: Boolean): PumpEnactResult = apply { this.queued = queued } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/BleClientContractTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/BleClientContractTest.kt new file mode 100644 index 000000000000..5e90a70a0a94 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/BleClientContractTest.kt @@ -0,0 +1,300 @@ +package app.aaps.pump.carelevo.ble + +import app.aaps.pump.carelevo.ble.gatt.FakeGattConnection +import app.aaps.pump.carelevo.ble.gatt.GattConnState +import app.aaps.pump.carelevo.ble.gatt.GattWriteException +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.UUID +import kotlin.test.assertFailsWith + +/** + * Executable specification for [BleClient]. + * + * Uses `runTest` virtual time throughout — no real-time waits, no `Thread.sleep`, + * no flakiness. `withTimeout(...)` advances virtual time deterministically. + * + * The client is created inside each test so it can be bound to the `TestScope` + * `backgroundScope` — its event collector runs on the test's scheduler and stops + * automatically at the end of the test. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class BleClientContractTest { + + private val writeUuid: UUID = UUID.fromString("00000001-0000-1000-8000-00805f9b34fb") + private val notifyUuid: UUID = UUID.fromString("00000002-0000-1000-8000-00805f9b34fb") + + private lateinit var gatt: FakeGattConnection + + @BeforeEach + fun setUp() { + gatt = FakeGattConnection() + } + + /** Construct the client and flush pending coroutines so the event collector is subscribed. */ + private fun TestScope.newClient(): BleClient { + val client = BleClientImpl(gatt, writeUuid, notifyUuid, backgroundScope) + runCurrent() + return client + } + + @Test + fun `01 opcode match happy path returns parsed response`() = runTest { + val client = newClient() + val cmd = fakeCommand( + requestOpcode = 0x24, + expectedResponseOpcode = 0x84.toByte(), + body = byteArrayOf(0x01, 0x02) + ) + gatt.onNextWrite { + gatt.deliverNotification( + notifyUuid, + byteArrayOf(0x84.toByte(), 0xFF.toByte(), 0x10) + ) + } + + val resp = client.request(cmd) + + assertThat(resp.raw.contentEquals(byteArrayOf(0x84.toByte(), 0xFF.toByte(), 0x10))).isTrue() + assertThat(gatt.recordedWrites).hasSize(1) + assertThat(gatt.recordedWrites.single().uuid).isEqualTo(writeUuid) + assertThat(gatt.recordedWrites.single().payload[0]).isEqualTo(0x24.toByte()) + } + + @Test + fun `02 mismatched response opcode is ignored waiter keeps waiting`() = runTest { + val client = newClient() + val cmd = fakeCommand( + requestOpcode = 0x24, + expectedResponseOpcode = 0x84.toByte(), + body = byteArrayOf(0x01) + ) + gatt.onNextWrite { + // Wrong opcode — BleClient must not complete the deferred with this. + gatt.deliverNotification(notifyUuid, byteArrayOf(0x85.toByte(), 0x00)) + } + + assertFailsWith { + withTimeout(500) { client.request(cmd) } + } + } + + @Test + fun `03 bolus actionId mismatch is ignored`() = runTest { + val client = newClient() + // Command expects actionId=3 echoed back at byte 1 of the response. + val cmd = fakeCommand( + requestOpcode = 0x24, + expectedResponseOpcode = 0x84.toByte(), + body = byteArrayOf(0x03), + correlationByte = 0x03 + ) + gatt.onNextWrite { + // Correct opcode but wrong actionId — must be rejected. + gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), 0x07, 0x00)) + } + + assertFailsWith { + withTimeout(500) { client.request(cmd) } + } + } + + @Test + fun `04 unsolicited alarm during request goes to unsolicitedEvents not the waiter`() = runTest { + val client = newClient() + val cmd = fakeCommand(0x24, 0x84.toByte(), byteArrayOf(0x01)) + val unsolicited = mutableListOf() + val collectorJob = launch { + client.unsolicitedEvents.collect { unsolicited += it } + } + runCurrent() // ensure collector is attached before we emit + + gatt.onNextWrite { + // Alarm (unsolicited) arrives during the request... + gatt.deliverNotification(notifyUuid, byteArrayOf(0xA1.toByte(), 0x11)) + // ...then the real response. + gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), 0x00)) + } + + val resp = client.request(cmd) + runCurrent() + + assertThat(resp.raw[0]).isEqualTo(0x84.toByte()) + assertThat(unsolicited).hasSize(1) + assertThat(unsolicited.single().opcode).isEqualTo(0xA1.toByte()) + collectorJob.cancel() + } + + @Test + fun `05 concurrent requests are serialized in call order`() = runTest { + val client = newClient() + val cmd1 = fakeCommand( + 0x24, 0x84.toByte(), byteArrayOf(0x01), correlationByte = 0x01 + ) + val cmd2 = fakeCommand( + 0x24, 0x84.toByte(), byteArrayOf(0x02), correlationByte = 0x02 + ) + + // Each write echoes back the actionId it received. + val echoResponse: suspend (FakeGattConnection.Write) -> Unit = { w -> + gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), w.payload[1], 0x00)) + } + gatt.onNextWrite(echoResponse) + gatt.onNextWrite(echoResponse) + + val a = async { client.request(cmd1) } + val b = async { client.request(cmd2) } + + a.await() + b.await() + + assertThat(gatt.recordedWrites).hasSize(2) + assertThat(gatt.recordedWrites[0].payload[1]).isEqualTo(0x01.toByte()) + assertThat(gatt.recordedWrites[1].payload[1]).isEqualTo(0x02.toByte()) + } + + @Test + fun `06 timeout leaves client in a clean state for the next request`() = runTest { + val client = newClient() + val cmd1 = fakeCommand(0x24, 0x84.toByte(), byteArrayOf(0x01)) + // No scripted response — pump stays silent. + + assertFailsWith { + withTimeout(500) { client.request(cmd1) } + } + + // Immediately issue another request; if BleClient leaked a deferred it would stay blocked. + val cmd2 = fakeCommand(0x25, 0x85.toByte(), byteArrayOf(0x01)) + gatt.onNextWrite { + gatt.deliverNotification(notifyUuid, byteArrayOf(0x85.toByte(), 0x00)) + } + + val resp = client.request(cmd2) + assertThat(resp.raw[0]).isEqualTo(0x85.toByte()) + } + + @Test + fun `07 BLE write failure surfaces as GattWriteException`() = runTest { + val client = newClient() + val cmd = fakeCommand(0x24, 0x84.toByte(), byteArrayOf(0x01)) + gatt.scriptNextWriteFailure("stack rejected") + + assertFailsWith { + client.request(cmd) + } + } + + @Test + fun `08 disconnect mid-request completes the waiter exceptionally`() = runTest { + val client = newClient() + val cmd = fakeCommand(0x24, 0x84.toByte(), byteArrayOf(0x01)) + gatt.onNextWrite { + gatt.deliverConnectionState(GattConnState.DISCONNECTED) + // No response ever delivered; disconnection must abort the request. + } + + assertFailsWith { + withTimeout(1000) { client.request(cmd) } + } + } + + @Test + fun `09 alarm when no request pending appears in unsolicitedEvents`() = runTest { + val client = newClient() + val collected = async { + client.unsolicitedEvents.take(2).toList() + } + runCurrent() // attach collector before emitting + + gatt.deliverNotification(notifyUuid, byteArrayOf(0xA1.toByte(), 0x22)) + gatt.deliverNotification(notifyUuid, byteArrayOf(0xA2.toByte(), 0x33)) + + val events = collected.await() + assertThat(events.map { it.opcode }).containsExactly(0xA1.toByte(), 0xA2.toByte()).inOrder() + } + + @Test + fun `10 response delivered synchronously during writeCharacteristic is not lost`() = runTest { + val client = newClient() + // The exact scenario the current PublishSubject+blockingFirst design fails: + // peripheral answers BEFORE the write call returns. BleClient must register + // its waiter BEFORE invoking the GATT write, so this can never race. + val cmd = fakeCommand(0x24, 0x84.toByte(), byteArrayOf(0x01)) + gatt.onNextWrite { + // Synchronously — still inside writeCharacteristic — deliver the response. + gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), 0x00)) + } + + val resp = client.request(cmd) + assertThat(resp.raw[0]).isEqualTo(0x84.toByte()) + } + + @Test + fun `11 notification after disconnect goes to unsolicitedEvents not dropped`() = runTest { + // Regression for a stale-waiter window: after DISCONNECTED completes the + // deferred exceptionally, [BleClientImpl] must clear [waiter] so a + // late-arriving notification falls through to unsolicitedEvents instead of + // hitting an already-completed deferred and being silently dropped. + val client = newClient() + val cmd = fakeCommand(0x24, 0x84.toByte(), byteArrayOf(0x01)) + val unsolicited = mutableListOf() + val collectorJob = launch { + client.unsolicitedEvents.collect { unsolicited += it } + } + runCurrent() + + gatt.onNextWrite { + gatt.deliverConnectionState(GattConnState.DISCONNECTED) + // Stale notification — its opcode matches the (now-aborted) waiter. + gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), 0x00)) + } + + assertFailsWith { + withTimeout(1000) { client.request(cmd) } + } + runCurrent() + + assertThat(unsolicited).hasSize(1) + assertThat(unsolicited.single().opcode).isEqualTo(0x84.toByte()) + collectorJob.cancel() + } + + // ===== Test fixtures ===== + + private data class FakeResponse(val raw: ByteArray) : BleResponse + + private class FakeCommandImpl( + override val requestOpcode: Byte, + override val expectedResponseOpcode: Byte, + private val body: ByteArray, + override val correlationByte: Byte? + ) : BleCommand { + + override fun encode(): ByteArray = byteArrayOf(requestOpcode) + body + override fun decode(responsePayload: ByteArray): FakeResponse = FakeResponse(responsePayload) + } + + private fun fakeCommand( + requestOpcode: Int, + expectedResponseOpcode: Byte, + body: ByteArray, + correlationByte: Byte? = null + ): BleCommand = + FakeCommandImpl( + requestOpcode = requestOpcode.toByte(), + expectedResponseOpcode = expectedResponseOpcode, + body = body, + correlationByte = correlationByte + ) +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/BleClientExtendedContractTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/BleClientExtendedContractTest.kt new file mode 100644 index 000000000000..95d16cd507f6 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/BleClientExtendedContractTest.kt @@ -0,0 +1,465 @@ +package app.aaps.pump.carelevo.ble + +import app.aaps.pump.carelevo.ble.gatt.FakeGattConnection +import app.aaps.pump.carelevo.ble.gatt.GattConnState +import app.aaps.pump.carelevo.ble.gatt.GattWriteException +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.UUID +import kotlin.test.assertFailsWith + +/** + * Executable specification for the extended [BleClient] surface: + * [BleClient.requestMultiple] (multi-response, e.g. Patch Info `0x33`→`0x93`+`0x94`) and + * [BleClient.requestStream] (streaming/progress, e.g. Safety Check `0x12`→`0x72`…). + * + * Same conventions as [BleClientContractTest]: `runTest` virtual time, `backgroundScope` + * for the client's event collector, `runCurrent()` to flush the subscription. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class BleClientExtendedContractTest { + + private val writeUuid: UUID = UUID.fromString("00000001-0000-1000-8000-00805f9b34fb") + private val notifyUuid: UUID = UUID.fromString("00000002-0000-1000-8000-00805f9b34fb") + + private lateinit var gatt: FakeGattConnection + + @BeforeEach + fun setUp() { + gatt = FakeGattConnection() + } + + private fun TestScope.newClient(): BleClient { + val client = BleClientImpl(gatt, writeUuid, notifyUuid, backgroundScope) + runCurrent() + return client + } + + // ===== Multi-response (requestMultiple) ===== + + @Test + fun `multi collects both opcodes and decodes from the map`() = runTest { + val client = newClient() + val cmd = fakeMultiCommand(0x33, setOf(0x93.toByte(), 0x94.toByte())) + gatt.onNextWrite { + gatt.deliverNotification(notifyUuid, byteArrayOf(0x93.toByte(), 0x00, 0x11)) + gatt.deliverNotification(notifyUuid, byteArrayOf(0x94.toByte(), 0x00, 0x22)) + } + + val resp = client.requestMultiple(cmd) + + assertThat(resp.parts.keys).containsExactly(0x93.toByte(), 0x94.toByte()) + assertThat(resp.parts[0x93.toByte()]!![2]).isEqualTo(0x11.toByte()) + assertThat(resp.parts[0x94.toByte()]!![2]).isEqualTo(0x22.toByte()) + assertThat(gatt.recordedWrites.single().payload[0]).isEqualTo(0x33.toByte()) + } + + @Test + fun `multi completes regardless of arrival order`() = runTest { + val client = newClient() + val cmd = fakeMultiCommand(0x33, setOf(0x93.toByte(), 0x94.toByte())) + gatt.onNextWrite { + // RPT2 first, then RPT1 — order must not matter. + gatt.deliverNotification(notifyUuid, byteArrayOf(0x94.toByte(), 0x00, 0x22)) + gatt.deliverNotification(notifyUuid, byteArrayOf(0x93.toByte(), 0x00, 0x11)) + } + + val resp = client.requestMultiple(cmd) + + assertThat(resp.parts.keys).containsExactly(0x93.toByte(), 0x94.toByte()) + } + + @Test + fun `multi waits for all opcodes - partial set times out`() = runTest { + val client = newClient() + val cmd = fakeMultiCommand(0x33, setOf(0x93.toByte(), 0x94.toByte())) + gatt.onNextWrite { + // Only RPT1 arrives; RPT2 never does. + gatt.deliverNotification(notifyUuid, byteArrayOf(0x93.toByte(), 0x00, 0x11)) + } + + assertFailsWith { + withTimeout(500) { client.requestMultiple(cmd) } + } + } + + @Test + fun `multi first notification per opcode wins - duplicate goes unsolicited`() = runTest { + val client = newClient() + val cmd = fakeMultiCommand(0x33, setOf(0x93.toByte(), 0x94.toByte())) + val unsolicited = mutableListOf() + val collectorJob = launch { client.unsolicitedEvents.collect { unsolicited += it } } + runCurrent() + + gatt.onNextWrite { + gatt.deliverNotification(notifyUuid, byteArrayOf(0x93.toByte(), 0x00, 0x11)) // first RPT1 (wins) + gatt.deliverNotification(notifyUuid, byteArrayOf(0x93.toByte(), 0x00, 0x99.toByte())) // duplicate RPT1 + gatt.deliverNotification(notifyUuid, byteArrayOf(0x94.toByte(), 0x00, 0x22)) // RPT2 completes it + } + + val resp = client.requestMultiple(cmd) + runCurrent() + + assertThat(resp.parts[0x93.toByte()]!![2]).isEqualTo(0x11.toByte()) // first, not 0x99 + assertThat(unsolicited).hasSize(1) + assertThat(unsolicited.single().opcode).isEqualTo(0x93.toByte()) + collectorJob.cancel() + } + + @Test + fun `multi routes an unsolicited alarm during collection to unsolicitedEvents`() = runTest { + val client = newClient() + val cmd = fakeMultiCommand(0x33, setOf(0x93.toByte(), 0x94.toByte())) + val unsolicited = mutableListOf() + val collectorJob = launch { client.unsolicitedEvents.collect { unsolicited += it } } + runCurrent() + + gatt.onNextWrite { + gatt.deliverNotification(notifyUuid, byteArrayOf(0x93.toByte(), 0x00, 0x11)) + gatt.deliverNotification(notifyUuid, byteArrayOf(0xA1.toByte(), 0x55)) // alarm mid-collection + gatt.deliverNotification(notifyUuid, byteArrayOf(0x94.toByte(), 0x00, 0x22)) + } + + val resp = client.requestMultiple(cmd) + runCurrent() + + assertThat(resp.parts.keys).containsExactly(0x93.toByte(), 0x94.toByte()) + assertThat(unsolicited.single().opcode).isEqualTo(0xA1.toByte()) + collectorJob.cancel() + } + + @Test + fun `multi disconnect mid-collection aborts with BleDisconnectedException`() = runTest { + val client = newClient() + val cmd = fakeMultiCommand(0x33, setOf(0x93.toByte(), 0x94.toByte())) + gatt.onNextWrite { + gatt.deliverNotification(notifyUuid, byteArrayOf(0x93.toByte(), 0x00, 0x11)) + gatt.deliverConnectionState(GattConnState.DISCONNECTED) // drop before RPT2 + } + + assertFailsWith { + withTimeout(1000) { client.requestMultiple(cmd) } + } + } + + @Test + fun `multi leaves a clean state for the next request`() = runTest { + val client = newClient() + val cmd1 = fakeMultiCommand(0x33, setOf(0x93.toByte(), 0x94.toByte())) + // Only one part arrives → times out. + gatt.onNextWrite { gatt.deliverNotification(notifyUuid, byteArrayOf(0x93.toByte(), 0x00)) } + assertFailsWith { + withTimeout(500) { client.requestMultiple(cmd1) } + } + + // A subsequent single request must work — no leaked waiter. + val cmd2 = fakeSingleCommand(0x24, 0x84.toByte()) + gatt.onNextWrite { gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), 0x00)) } + val resp = client.request(cmd2) + assertThat(resp.raw[0]).isEqualTo(0x84.toByte()) + } + + // ===== Streaming (requestStream) ===== + + @Test + fun `stream emits progress then terminal and completes`() = runTest { + val client = newClient() + val cmd = fakeStreamCommand(0x12, 0x72.toByte()) + gatt.onNextWrite { + gatt.deliverNotification(notifyUuid, byteArrayOf(0x72.toByte(), PROGRESS)) // progress + gatt.deliverNotification(notifyUuid, byteArrayOf(0x72.toByte(), TERMINAL)) // SUCCESS + } + + val results = client.requestStream(cmd).toList() + + assertThat(results).hasSize(2) + assertThat(results.map { it.terminal }).containsExactly(false, true).inOrder() + } + + @Test + fun `stream emits multiple progress events before terminal`() = runTest { + val client = newClient() + val cmd = fakeStreamCommand(0x12, 0x72.toByte()) + gatt.onNextWrite { + gatt.deliverNotification(notifyUuid, byteArrayOf(0x72.toByte(), PROGRESS)) + gatt.deliverNotification(notifyUuid, byteArrayOf(0x72.toByte(), PROGRESS)) + gatt.deliverNotification(notifyUuid, byteArrayOf(0x72.toByte(), TERMINAL)) + } + + val results = client.requestStream(cmd).toList() + + assertThat(results.map { it.terminal }).containsExactly(false, false, true).inOrder() + } + + @Test + fun `stream ignores a non-matching notification - routed to unsolicitedEvents`() = runTest { + val client = newClient() + val cmd = fakeStreamCommand(0x12, 0x72.toByte()) + val unsolicited = mutableListOf() + val collectorJob = launch { client.unsolicitedEvents.collect { unsolicited += it } } + runCurrent() + + gatt.onNextWrite { + gatt.deliverNotification(notifyUuid, byteArrayOf(0x72.toByte(), PROGRESS)) + gatt.deliverNotification(notifyUuid, byteArrayOf(0xA1.toByte(), 0x55)) // alarm during stream + gatt.deliverNotification(notifyUuid, byteArrayOf(0x72.toByte(), TERMINAL)) + } + + val results = client.requestStream(cmd).toList() + runCurrent() + + assertThat(results.map { it.terminal }).containsExactly(false, true).inOrder() + assertThat(unsolicited.single().opcode).isEqualTo(0xA1.toByte()) + collectorJob.cancel() + } + + @Test + fun `stream disconnect mid-stream throws BleDisconnectedException`() = runTest { + val client = newClient() + val cmd = fakeStreamCommand(0x12, 0x72.toByte()) + gatt.onNextWrite { + gatt.deliverNotification(notifyUuid, byteArrayOf(0x72.toByte(), PROGRESS)) + gatt.deliverConnectionState(GattConnState.DISCONNECTED) // drop before terminal + } + + assertFailsWith { + withTimeout(1000) { client.requestStream(cmd).toList() } + } + } + + @Test + fun `stream without terminal times out`() = runTest { + val client = newClient() + val cmd = fakeStreamCommand(0x12, 0x72.toByte()) + gatt.onNextWrite { + gatt.deliverNotification(notifyUuid, byteArrayOf(0x72.toByte(), PROGRESS)) // no terminal ever + } + + assertFailsWith { + withTimeout(500) { client.requestStream(cmd).toList() } + } + } + + @Test + fun `stream holds the request slot until it terminates - a concurrent request waits`() = runTest { + val client = newClient() + val stream = fakeStreamCommand(0x12, 0x72.toByte()) + val single = fakeSingleCommand(0x24, 0x84.toByte()) + + // The stream write delivers only progress until we release it below. + gatt.onNextWrite { + gatt.deliverNotification(notifyUuid, byteArrayOf(0x72.toByte(), PROGRESS)) + } + // The single request's write (runs only after the stream frees the mutex). + gatt.onNextWrite { + gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), 0x00)) + } + + val streamResults = mutableListOf() + val streamJob = async { client.requestStream(stream).toList().also { streamResults += it } } + val singleJob = async { client.request(single) } + runCurrent() + + // While the stream is open (only progress so far) the single request cannot have run. + assertThat(gatt.recordedWrites).hasSize(1) + assertThat(singleJob.isCompleted).isFalse() + + // Deliver the terminal → stream completes, mutex frees, single request proceeds. + gatt.deliverNotification(notifyUuid, byteArrayOf(0x72.toByte(), TERMINAL)) + streamJob.await() + val resp = singleJob.await() + + assertThat(streamResults.map { it.terminal }).containsExactly(false, true).inOrder() + assertThat(resp.raw[0]).isEqualTo(0x84.toByte()) + assertThat(gatt.recordedWrites).hasSize(2) + } + + @Test + fun `stream isTerminal throw ends the stream and does not brick the client`() = runTest { + val client = newClient() + val cmd = object : BleStreamCommand { + override val requestOpcode: Byte = 0x12 + override val expectedResponseOpcode: Byte = 0x72.toByte() + override fun encode(): ByteArray = byteArrayOf(requestOpcode) + override fun decode(responsePayload: ByteArray) = FakeStreamResponse(responsePayload, terminal = false) + override fun isTerminal(response: FakeStreamResponse): Boolean = error("boom in isTerminal") + } + gatt.onNextWrite { gatt.deliverNotification(notifyUuid, byteArrayOf(0x72.toByte(), PROGRESS)) } + + // The stream fails (not hangs) with the thrown cause... + assertFailsWith { + withTimeout(1000) { client.requestStream(cmd).toList() } + } + // ...and — critically — the sole event collector survived: a later request works. + val single = fakeSingleCommand(0x24, 0x84.toByte()) + gatt.onNextWrite { gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), 0x00)) } + assertThat(client.request(single).raw[0]).isEqualTo(0x84.toByte()) + } + + @Test + fun `stream decode failure ends the stream with the error and does not leak to unsolicited`() = runTest { + val client = newClient() + val unsolicited = mutableListOf() + val collectorJob = launch { client.unsolicitedEvents.collect { unsolicited += it } } + runCurrent() + val cmd = object : BleStreamCommand { + override val requestOpcode: Byte = 0x12 + override val expectedResponseOpcode: Byte = 0x72.toByte() + override fun encode(): ByteArray = byteArrayOf(requestOpcode) + override fun decode(responsePayload: ByteArray): FakeStreamResponse { + if (responsePayload.size > 1 && responsePayload[1] == POISON) error("boom in decode") + return FakeStreamResponse(responsePayload, terminal = responsePayload.size > 1 && responsePayload[1] == TERMINAL) + } + + override fun isTerminal(response: FakeStreamResponse): Boolean = response.terminal + } + gatt.onNextWrite { + gatt.deliverNotification(notifyUuid, byteArrayOf(0x72.toByte(), PROGRESS)) + gatt.deliverNotification(notifyUuid, byteArrayOf(0x72.toByte(), POISON)) // decode throws on this + } + + assertFailsWith { + withTimeout(1000) { client.requestStream(cmd).toList() } + } + runCurrent() + + assertThat(unsolicited).isEmpty() // poison frame consumed by the stream, not leaked + val single = fakeSingleCommand(0x24, 0x84.toByte()) + gatt.onNextWrite { gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), 0x00)) } + assertThat(client.request(single).raw[0]).isEqualTo(0x84.toByte()) + collectorJob.cancel() + } + + @Test + fun `stream cancelled mid-flight frees the request slot for the next request`() = runTest { + val client = newClient() + val cmd = fakeStreamCommand(0x12, 0x72.toByte()) + gatt.onNextWrite { gatt.deliverNotification(notifyUuid, byteArrayOf(0x72.toByte(), PROGRESS)) } + + // Collect the (still-open, no terminal) stream in a job, then cancel it mid-flight. + val streamJob = launch { client.requestStream(cmd).collect { } } + runCurrent() + streamJob.cancel() + streamJob.join() + + // The long-held request mutex must have been freed on cancellation. + val single = fakeSingleCommand(0x24, 0x84.toByte()) + gatt.onNextWrite { gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), 0x00)) } + assertThat(client.request(single).raw[0]).isEqualTo(0x84.toByte()) + } + + @Test + fun `stream is cold - no write until collected`() = runTest { + val client = newClient() + val cmd = fakeStreamCommand(0x12, 0x72.toByte()) + + val flow = client.requestStream(cmd) + runCurrent() + assertThat(gatt.recordedWrites).isEmpty() // not collected → no write, no mutex taken + + gatt.onNextWrite { gatt.deliverNotification(notifyUuid, byteArrayOf(0x72.toByte(), TERMINAL)) } + flow.toList() + assertThat(gatt.recordedWrites).hasSize(1) + } + + @Test + fun `stream write failure surfaces GattWriteException and leaves clean state`() = runTest { + val client = newClient() + val cmd = fakeStreamCommand(0x12, 0x72.toByte()) + gatt.scriptNextWriteFailure("stack rejected") + + assertFailsWith { client.requestStream(cmd).toList() } + + val single = fakeSingleCommand(0x24, 0x84.toByte()) + gatt.onNextWrite { gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), 0x00)) } + assertThat(client.request(single).raw[0]).isEqualTo(0x84.toByte()) + } + + @Test + fun `multi with empty expectedResponseOpcodes fails fast`() = runTest { + val client = newClient() + + assertFailsWith { + client.requestMultiple(fakeMultiCommand(0x33, emptySet())) + } + + // The guard fires before the write, releasing the mutex with no leaked waiter. + val single = fakeSingleCommand(0x24, 0x84.toByte()) + gatt.onNextWrite { gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), 0x00)) } + assertThat(client.request(single).raw[0]).isEqualTo(0x84.toByte()) + } + + @Test + fun `multi with a single-opcode set completes on one notification`() = runTest { + val client = newClient() + val cmd = fakeMultiCommand(0x33, setOf(0x93.toByte())) + gatt.onNextWrite { gatt.deliverNotification(notifyUuid, byteArrayOf(0x93.toByte(), 0x00, 0x11)) } + + val resp = client.requestMultiple(cmd) + + assertThat(resp.parts.keys).containsExactly(0x93.toByte()) + } + + @Test + fun `multi write failure surfaces GattWriteException and leaves clean state`() = runTest { + val client = newClient() + val cmd = fakeMultiCommand(0x33, setOf(0x93.toByte(), 0x94.toByte())) + gatt.scriptNextWriteFailure("stack rejected") + + assertFailsWith { client.requestMultiple(cmd) } + + val single = fakeSingleCommand(0x24, 0x84.toByte()) + gatt.onNextWrite { gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), 0x00)) } + assertThat(client.request(single).raw[0]).isEqualTo(0x84.toByte()) + } + + // ===== Test fixtures ===== + + private data class FakeSingleResponse(val raw: ByteArray) : BleResponse + private data class FakeMultiResponse(val parts: Map) : BleResponse + private data class FakeStreamResponse(val raw: ByteArray, val terminal: Boolean) : BleResponse + + private fun fakeSingleCommand(requestOpcode: Int, expected: Byte): BleCommand = + object : BleCommand { + override val requestOpcode: Byte = requestOpcode.toByte() + override val expectedResponseOpcode: Byte = expected + override fun encode(): ByteArray = byteArrayOf(this.requestOpcode) + override fun decode(responsePayload: ByteArray) = FakeSingleResponse(responsePayload) + } + + private fun fakeMultiCommand(requestOpcode: Int, expected: Set): BleMultiCommand = + object : BleMultiCommand { + override val requestOpcode: Byte = requestOpcode.toByte() + override val expectedResponseOpcodes: Set = expected + override fun encode(): ByteArray = byteArrayOf(this.requestOpcode) + override fun decode(responses: Map) = FakeMultiResponse(responses) + } + + private fun fakeStreamCommand(requestOpcode: Int, expected: Byte): BleStreamCommand = + object : BleStreamCommand { + override val requestOpcode: Byte = requestOpcode.toByte() + override val expectedResponseOpcode: Byte = expected + override fun encode(): ByteArray = byteArrayOf(this.requestOpcode) + override fun decode(responsePayload: ByteArray) = + FakeStreamResponse(responsePayload, terminal = responsePayload.size > 1 && responsePayload[1] == TERMINAL) + + override fun isTerminal(response: FakeStreamResponse): Boolean = response.terminal + } + + private companion object { + + const val PROGRESS: Byte = 0x04 // REP_REQUEST — a progress notification + const val TERMINAL: Byte = 0x00 // SUCCESS — the terminal notification + const val POISON: Byte = 0x7F // a frame whose decode() throws, for the decode-failure test + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/BleClientRoutingContractTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/BleClientRoutingContractTest.kt new file mode 100644 index 000000000000..748352bcc553 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/BleClientRoutingContractTest.kt @@ -0,0 +1,221 @@ +package app.aaps.pump.carelevo.ble + +import app.aaps.pump.carelevo.ble.gatt.FakeGattConnection +import app.aaps.pump.carelevo.ble.gatt.GattConnState +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.UUID + +/** + * Executable specification for [BleClientImpl]'s **event router** — the paths a happy-path exchange never + * reaches, and which exist precisely because a real pump does not cooperate: + * - a frame the active waiter cannot consume (duplicate / already-settled) falling through to + * [BleClient.unsolicitedEvents] instead of vanishing inside a settled waiter, + * - the **abandoned waiter** re-route: the requester unwound (cancel/timeout) after the router had already + * absorbed the response, so the frame is handed back and re-routed rather than silently swallowed — the + * real case being the ack of a bolus that DID start while our side gave up, + * - non-notification events (connection-state, discovery, write-ack) staying inert. + * + * Companion to [BleClientContractTest] (single-response correlation) and [BleClientExtendedContractTest] + * (multi/stream); same conventions — `runTest` virtual time, [FakeGattConnection], `runCurrent()` to flush + * the router subscription. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class BleClientRoutingContractTest { + + private val writeUuid: UUID = UUID.fromString("00000001-0000-1000-8000-00805f9b34fb") + private val notifyUuid: UUID = UUID.fromString("00000002-0000-1000-8000-00805f9b34fb") + + private lateinit var gatt: FakeGattConnection + private var inlineRouterScope: CoroutineScope? = null + + @BeforeEach + fun setUp() { + gatt = FakeGattConnection() + } + + @AfterEach + fun tearDown() { + // Not a child of the test job (see newInlineRouterClient), so stop it explicitly. + inlineRouterScope?.cancel() + } + + /** Router on the test scheduler — flushed with `runCurrent()`, as in the sibling contract tests. */ + private fun TestScope.newClient(): BleClient { + val client = BleClientImpl(gatt, writeUuid, notifyUuid, backgroundScope) + runCurrent() + return client + } + + /** + * A client whose event router runs **unconfined**, so [FakeGattConnection.deliverNotification] routes + * the frame INLINE, inside the `deliverNotification` call. + * + * That is what makes the abandon race reachable: the router completes the waiter's deferred, which only + * *queues* the requester's continuation on the test dispatcher, so the test can cancel the requester in + * that window. With the router on the same StandardTestDispatcher both would drain inside one + * `runCurrent()` and the request would always win. + */ + private fun TestScope.newInlineRouterClient(): BleClient { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + inlineRouterScope = scope + // Unconfined: the router's collect subscribes eagerly during construction, so no runCurrent(). + return BleClientImpl(gatt, writeUuid, notifyUuid, scope) + } + + // ===== Frames the active waiter cannot consume ===== + + @Test + fun `a duplicate response frame arriving before the requester resumes goes to unsolicitedEvents`() = runTest { + val client = newClient() + val unsolicited = mutableListOf() + val collectorJob = launch { client.unsolicitedEvents.collect { unsolicited += it } } + runCurrent() + val cmd = fakeSingleCommand(0x24, 0x84.toByte()) + gatt.onNextWrite { + gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), 0x01)) // the real response + gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), 0x02)) // pump repeats it + } + + // Both frames are routed before the request's continuation runs — the waiter is still registered + // for the duplicate, and its deferred is already settled. + val resp = client.request(cmd) + runCurrent() + + assertThat(resp.raw[1]).isEqualTo(0x01.toByte()) // first frame wins the deferred + assertThat(unsolicited).hasSize(1) + assertThat(unsolicited.single().payload[1]).isEqualTo(0x02.toByte()) + collectorJob.cancel() + } + + @Test + fun `an empty notification payload is dropped and does not disturb routing`() = runTest { + val client = newClient() + val unsolicited = mutableListOf() + val collectorJob = launch { client.unsolicitedEvents.collect { unsolicited += it } } + runCurrent() + + gatt.deliverNotification(notifyUuid, byteArrayOf()) // no opcode byte to route on + runCurrent() + + assertThat(unsolicited).isEmpty() + // The router shrugged it off: a later request still correlates. + val cmd = fakeSingleCommand(0x24, 0x84.toByte()) + gatt.onNextWrite { gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), 0x00)) } + assertThat(client.request(cmd).raw[0]).isEqualTo(0x84.toByte()) + collectorJob.cancel() + } + + // ===== Abandoned waiters (requester unwound without consuming the response) ===== + + @Test + fun `a single request abandoned after the router absorbed its response re-routes it to unsolicitedEvents`() = runTest { + val client = newInlineRouterClient() + val unsolicited = mutableListOf() + val collectorJob = launch { client.unsolicitedEvents.collect { unsolicited += it } } + runCurrent() + val cmd = fakeSingleCommand(0x24, 0x84.toByte()) + val pending = launch { client.request(cmd) } + runCurrent() // write out; waiter registered and awaiting + + // Routed INLINE → the deferred completes, but the requester's continuation is only queued... + gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), 0x42)) + // ...and the requester is cancelled before it can run, so nobody ever consumes the response the + // pump really sent. + pending.cancel() + runCurrent() + + // It must resurface as unsolicited rather than being silently swallowed. + assertThat(unsolicited).hasSize(1) + assertThat(unsolicited.single().opcode).isEqualTo(0x84.toByte()) + assertThat(unsolicited.single().payload[1]).isEqualTo(0x42.toByte()) + collectorJob.cancel() + } + + @Test + fun `a multi request abandoned after collecting its frames re-routes all of them to unsolicitedEvents`() = runTest { + val client = newInlineRouterClient() + val unsolicited = mutableListOf() + val collectorJob = launch { client.unsolicitedEvents.collect { unsolicited += it } } + runCurrent() + val cmd = fakeMultiCommand(0x33, setOf(0x93.toByte(), 0x94.toByte())) + val pending = launch { client.requestMultiple(cmd) } + runCurrent() + + gatt.deliverNotification(notifyUuid, byteArrayOf(0x93.toByte(), 0x00, 0x11)) + gatt.deliverNotification(notifyUuid, byteArrayOf(0x94.toByte(), 0x00, 0x22)) // completes the waiter + pending.cancel() + runCurrent() + + // Every collected part comes back out — a complete round is never lost just because our side unwound. + assertThat(unsolicited.map { it.opcode }).containsExactly(0x93.toByte(), 0x94.toByte()) + collectorJob.cancel() + } + + // ===== Non-notification events ===== + + @Test + fun `a CONNECTED state change does not abort the pending request`() = runTest { + val client = newClient() + val cmd = fakeSingleCommand(0x24, 0x84.toByte()) + gatt.onNextWrite { + // Only DISCONNECTED aborts the waiter; every other transition must be inert. + gatt.deliverConnectionState(GattConnState.CONNECTED) + gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), 0x00)) + } + + assertThat(client.request(cmd).raw[0]).isEqualTo(0x84.toByte()) + } + + @Test + fun `service-discovery and write-ack events are ignored by the router`() = runTest { + val client = newClient() + val unsolicited = mutableListOf() + val collectorJob = launch { client.unsolicitedEvents.collect { unsolicited += it } } + runCurrent() + + gatt.discoverServices() // emits ServicesDiscovered on the same events flow + runCurrent() + + val cmd = fakeSingleCommand(0x24, 0x84.toByte()) + gatt.onNextWrite { gatt.deliverNotification(notifyUuid, byteArrayOf(0x84.toByte(), 0x00)) } + assertThat(client.request(cmd).raw[0]).isEqualTo(0x84.toByte()) + runCurrent() + + // Neither ServicesDiscovered nor the write's WriteAck is a protocol frame — they must not reach + // the alarm/status consumers. + assertThat(unsolicited).isEmpty() + collectorJob.cancel() + } + + // ===== Test fixtures ===== + + private data class FakeSingleResponse(val raw: ByteArray) : BleResponse + private data class FakeMultiResponse(val parts: Map) : BleResponse + + private fun fakeSingleCommand(requestOpcode: Int, expected: Byte): BleCommand = + object : BleCommand { + override val requestOpcode: Byte = requestOpcode.toByte() + override val expectedResponseOpcode: Byte = expected + override fun encode(): ByteArray = byteArrayOf(this.requestOpcode) + override fun decode(responsePayload: ByteArray) = FakeSingleResponse(responsePayload) + } + + private fun fakeMultiCommand(requestOpcode: Int, expected: Set): BleMultiCommand = + object : BleMultiCommand { + override val requestOpcode: Byte = requestOpcode.toByte() + override val expectedResponseOpcodes: Set = expected + override fun encode(): ByteArray = byteArrayOf(this.requestOpcode) + override fun decode(responses: Map) = FakeMultiResponse(responses) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/CarelevoBleSessionPairingTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/CarelevoBleSessionPairingTest.kt new file mode 100644 index 000000000000..8c2890822a3a --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/CarelevoBleSessionPairingTest.kt @@ -0,0 +1,648 @@ +package app.aaps.pump.carelevo.ble + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.pump.ble.BleAdapter +import app.aaps.core.interfaces.pump.ble.BleGatt +import app.aaps.core.interfaces.pump.ble.BleScanner +import app.aaps.core.interfaces.pump.ble.BleTransportListener +import app.aaps.core.interfaces.pump.ble.PairingState +import app.aaps.core.interfaces.pump.ble.ScannedDevice +import app.aaps.pump.carelevo.ble.commands.AlertAlarmSetCommand +import app.aaps.pump.carelevo.ble.commands.SafetyCheckResponse +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import java.util.UUID +import kotlin.experimental.xor +import kotlin.test.assertFailsWith + +/** + * Tests [CarelevoBleSession] against a scripted fake pump. + * + * [CarelevoBleSession.runPairing] — including the **set-time retry round**: each `requestMultiple` round + * registers a fresh waiter, so rounds stay isolated and the scenario is scriptable — round 1 answers with + * an empty serial, round 2 with a valid one, and the retry is observable on the wire (two 0x11 writes). + * + * Then the other session ops ([CarelevoBleSession.readInfusionInfo], [CarelevoBleSession.runSingle], + * [CarelevoBleSession.runSafetyCheck], [CarelevoBleSession.runBasalProgram]) and the `withSession` + * guarantees they all share: address normalization, connect refusal/timeout, the bond poll, the + * close-and-release teardown on a blown deadline, and the inter-session settle spacing. + * + * Uses `runTest` virtual time with the session's `sessionDispatcher` test seam — the events SharedFlow + * has no replay, so with real dispatchers an instantly-scripted response can be emitted before the + * client's router subscription is live and be lost (a suite-load flake); the shared test scheduler + * makes subscription/emission ordering deterministic. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class CarelevoBleSessionPairingTest { + + private val writeUuid: UUID = UUID.fromString("e1b40002-ffc4-4daa-a49b-1c92f99072ab") + private val notifyUuid: UUID = UUID.fromString("e1b40003-ffc4-4daa-a49b-1c92f99072ab") + private val aapsLogger: AAPSLogger = mock() + + private lateinit var transport: FakePairingTransport + private lateinit var session: CarelevoBleSession + + private val spec = CarelevoBleSession.PairingSpec( + volume = 300, + remains = 30, + expiry = 120, + maxBasalSpeed = 15.0, + maxBolusDose = 25.0, + buzzUse = true + ) + + @BeforeEach + fun setUp() { + transport = FakePairingTransport() + session = CarelevoBleSession(transport, writeUuid, notifyUuid, aapsLogger) + } + + /** Route the session's internal scopes (per-session + queue connect/disconnect) onto this test's scheduler. */ + private fun TestScope.useTestDispatcher() { + val dispatcher = StandardTestDispatcher(testScheduler) + session.sessionDispatcher = dispatcher + session.linkDispatcher = dispatcher + } + + @Test + fun `runPairing retries the set-time round when the serial comes back empty`() = runTest { + useTestDispatcher() + transport.emptySerialRounds = 1 + + val result = session.runPairing("94:B2:16:1D:2F:6D", spec) + + // Retry observable on the wire: two 0x11 set-time writes (round 1 empty serial → round 2 valid). + assertThat(transport.writes.count { it[0] == SET_TIME_OPCODE }).isEqualTo(2) + assertThat(result.serialNumber).isEqualTo(SERIAL) + assertThat(result.firmwareVersion).isEqualTo("T168") + assertThat(result.modelName).isEqualTo("6776514848") + // Colon MAC built from the 0x9B response bytes, lowercase. + assertThat(result.address).isEqualTo("94:b2:16:1d:2f:6d") + + // The auth write must carry checkSumV2(key) over (MAC bytes + checksum byte), seeded with the + // random key echoed in the 0x3B request — recompute from the captured wire traffic. + val key = transport.writes.first { it[0] == MAC_REQUEST_OPCODE }[1] + val expectedCheckSum = (MAC_BYTES + CHECKSUM_BYTE).fold(key) { acc, b -> acc xor b } + val authWrite = transport.writes.first { it[0] == APP_AUTH_OPCODE } + assertThat(authWrite[1]).isEqualTo(expectedCheckSum) + } + + @Test + fun `runPairing succeeds on the first round with a single set-time write`() = runTest { + useTestDispatcher() + transport.emptySerialRounds = 0 + + val result = session.runPairing("94:B2:16:1D:2F:6D", spec) + + assertThat(transport.writes.count { it[0] == SET_TIME_OPCODE }).isEqualTo(1) + assertThat(result.serialNumber).isEqualTo(SERIAL) + // Full activation sequence on ONE session, in protocol order. + assertThat(transport.writes.map { it[0] }).isEqualTo( + listOf(MAC_REQUEST_OPCODE, APP_AUTH_OPCODE, SET_TIME_OPCODE, ALERT_ALARM_OPCODE, THRESHOLD_OPCODE) + ) + // Threshold bundle carries the spec verbatim: remains, expiry, basal/bolus unit+centi, buzz. + val threshold = transport.writes.first { it[0] == THRESHOLD_OPCODE } + assertThat(threshold).isEqualTo(byteArrayOf(THRESHOLD_OPCODE, 30, 120, 15, 0, 25, 0, 0x01)) + } + + @Test + fun `runPairing fails when the serial stays empty after all rounds`(): Unit = runTest { + useTestDispatcher() + transport.emptySerialRounds = Int.MAX_VALUE + + val e = assertFailsWith { + session.runPairing("94:B2:16:1D:2F:6D", spec) + } + + assertThat(e.message).contains("patch info invalid") + // Both rounds were really attempted before giving up. + assertThat(transport.writes.count { it[0] == SET_TIME_OPCODE }).isEqualTo(2) + } + + @Test + fun `runPairing creates the bond when the device is not bonded`() = runTest { + useTestDispatcher() + transport.bonded = false + + session.runPairing("94:B2:16:1D:2F:6D", spec) + + // createBond flips the fake to bonded, so the poll exits immediately. + assertThat(transport.createBondCalls).isEqualTo(1) + } + + @Test + fun `runPairing times out when the bond never completes`() = runTest { + useTestDispatcher() + transport.bonded = false + transport.bondOnCreate = false // SMP never finishes → the poll never sees a bond + + assertFailsWith { session.runPairing(ADDRESS, spec) } + + // The bond wait is bounded on its own (15 s), inside the wider open() budget: createBond was + // issued, the poll gave up, and no protocol write ever went out on an unbonded link. + assertThat(transport.createBondCalls).isEqualTo(1) + assertThat(testScheduler.currentTime).isEqualTo(BOND_TIMEOUT_MS) + assertThat(transport.writes).isEmpty() + } + + // ===== The other session ops ===== + + @Test + fun `readInfusionInfo runs the status read and decodes the report`() = runTest { + useTestDispatcher() + + val info = session.readInfusionInfo(ADDRESS) + + assertThat(transport.writes.map { it[0] }).isEqualTo(listOf(INFUSION_INFO_OPCODE)) + assertThat(info.runningMinutes).isEqualTo(150) + assertThat(info.insulinRemaining).isEqualTo(150.5) + assertThat(info.infusedTotalBasalAmount).isEqualTo(10.25) + assertThat(info.infusedTotalBolusAmount).isEqualTo(5.5) + assertThat(info.pumpStateRaw).isEqualTo(2) + assertThat(info.modeRaw).isEqualTo(1) + } + + @Test + fun `runSingle runs an arbitrary command on a fresh session`() = runTest { + useTestDispatcher() + + val result = session.runSingle(ADDRESS, AlertAlarmSetCommand(3)) + + assertThat(result.resultCode).isEqualTo(0) + assertThat(transport.writes.single()).isEqualTo(byteArrayOf(ALERT_ALARM_OPCODE, 3)) + } + + @Test + fun `runSafetyCheck reports every progress frame and completes on the terminal frame`() = runTest { + useTestDispatcher() + transport.safetyCheckProgressFrames = 2 + + val frames = mutableListOf() + session.runSafetyCheck(ADDRESS) { frames += it } + + // onFrame sees each progress report as the pump streams it, then the terminal SUCCESS. + assertThat(frames.map { it.resultCode }).isEqualTo(listOf(SAFETY_PROGRESS_RESULT, SAFETY_PROGRESS_RESULT, 0)) + assertThat(frames.last().insulinVolume).isEqualTo(210) + assertThat(frames.last().durationSeconds).isEqualTo(210) + assertThat(transport.writes.single()[0]).isEqualTo(SAFETY_CHECK_OPCODE) + } + + @Test + fun `runBasalProgram writes every seqNo on ONE session and reports success`() = runTest { + useTestDispatcher() + + val ok = session.runBasalProgram(ADDRESS, listOf(listOf(1.0), listOf(2.0), listOf(3.0))) + + assertThat(ok).isTrue() + assertThat(transport.writes.map { it[0] }).isEqualTo(List(3) { BASAL_SET_OPCODE }) + assertThat(transport.writes.map { it[1] }).isEqualTo(listOf(0, 1, 2)) + // One session for all three: the link was opened once and released once. + assertThat(transport.connectAddresses).hasSize(1) + } + + @Test + fun `runBasalProgram short-circuits on the first rejected seqNo`() = runTest { + useTestDispatcher() + transport.basalRejectAtSeq = 1 + + val ok = session.runBasalProgram(ADDRESS, listOf(listOf(1.0), listOf(2.0), listOf(3.0))) + + assertThat(ok).isFalse() + // seqNo 2 must NOT follow a rejected seqNo 1 — no partial program left on the pump. + assertThat(transport.writes.map { it[1] }).isEqualTo(listOf(0, 1)) + } + + @Test + fun `runBasalProgram update sends the mid-therapy change opcode`() = runTest { + useTestDispatcher() + + val ok = session.runBasalProgram(ADDRESS, listOf(listOf(1.0)), isUpdate = true) + + assertThat(ok).isTrue() + assertThat(transport.writes.single()[0]).isEqualTo(BASAL_UPDATE_OPCODE) + } + + // ===== withSession: open, teardown, spacing ===== + + @Test + fun `session normalizes the stored lowercase address before dialing`() = runTest { + useTestDispatcher() + + session.readInfusionInfo("94:b2:16:1d:2f:6d") + + // BluetoothAdapter.getRemoteDevice throws on a lowercase MAC; the stored address is lowercase. + assertThat(transport.connectAddresses).containsExactly(ADDRESS) + } + + @Test + fun `session fails fast when the BLE stack refuses the connect`() = runTest { + useTestDispatcher() + transport.connectRefused = true + + val e = assertFailsWith { session.readInfusionInfo(ADDRESS) } + + assertThat(e.message).contains("connect() refused") + assertThat(transport.writes).isEmpty() + // The refused session still released the transport's single listener slot. + assertThat(transport.capturedListener).isNull() + } + + @Test + fun `session times out when the patch never reports CONNECTED`() = runTest { + useTestDispatcher() + transport.reportConnected = false + + assertFailsWith { session.readInfusionInfo(ADDRESS) } + + // The whole connect→discover→enable handshake is bounded, so a lost callback cannot suspend + // forever while holding sessionMutex. + assertThat(testScheduler.currentTime).isEqualTo(CONNECT_TIMEOUT_MS) + assertThat(transport.writes).isEmpty() + assertThat(transport.capturedListener).isNull() + } + + @Test + fun `an op that blows its deadline still tears the session down and frees the next one`() = runTest { + useTestDispatcher() + transport.silentOpcodes += INFUSION_INFO_OPCODE // patch never answers 0x31 + + assertFailsWith { session.readInfusionInfo(ADDRESS) } + + // finally: the gatt was closed (listener slot released) despite the blown deadline... + assertThat(transport.capturedListener).isNull() + // ...and sessionMutex was freed, so a later op — e.g. an out-of-band bolus cancel — still runs. + assertThat(session.runSingle(ADDRESS, AlertAlarmSetCommand(0)).resultCode).isEqualTo(0) + } + + @Test + fun `back-to-back sessions wait out the inter-session settle before reconnecting`() = runTest { + useTestDispatcher() + // Warm-up session: the first session has no previous close to space out from, and it primes + // class loading so the wall-clock gap the settle measures stays far below its 1 s window. + session.runSingle(ADDRESS, AlertAlarmSetCommand(0)) + val afterFirst = testScheduler.currentTime + + session.runSingle(ADDRESS, AlertAlarmSetCommand(0)) + val afterSecond = testScheduler.currentTime + + assertThat(afterFirst).isEqualTo(0L) // nothing to settle from on the first session + // The second dial waits (near enough) the full settle: the patch needs time to release the link. + assertThat(afterSecond - afterFirst).isAtLeast(SETTLE_LOWER_BOUND_MS) + assertThat(afterSecond - afterFirst).isAtMost(INTER_SESSION_SETTLE_MS) + } + + // ===== Queue-owned-link lifecycle: PR #5003 surface (see _docs/CARELEVO_QUEUE_OWNED_LINK.md) ===== + // + // Suspend-with-active-temp (CarelevoOverviewViewModel.startPumpStopProcess) runs cancelTempBasal as one + // queue command then CmdPumpStop as a second. Under the per-op/transient model each is its own session, + // so the 2nd re-dials while the 1st tears down — PR #5003's handshake-drop race. The queue-owned held + // link collapses both ops onto ONE connection, removing the second handshake entirely. + + @Test + fun `off-queue back-to-back ops each open their own transient session`() = runTest { + useTestDispatcher() + + // No held link (pre-activation / off-queue): two ops = two transient sessions = two connects. + session.readInfusionInfo(ADDRESS) + session.readInfusionInfo(ADDRESS) + + assertThat(transport.connectAddresses).hasSize(2) + } + + @Test + fun `a handshake drop on a transient second session fails the second op`() = runTest { + useTestDispatcher() + // The 2nd connect succeeds at the BLE layer but the patch never reaches STATE_CONNECTED — a + // handshake drop on the re-dial (what PR #5003 observed and retries). + transport.dropConnectAtIndex = 2 + + session.readInfusionInfo(ADDRESS) // op 1: clean session + + assertFailsWith { + session.readInfusionInfo(ADDRESS) // op 2: fresh handshake drops → the op fails + } + assertThat(transport.connectAddresses).hasSize(2) + } + + @Test + fun `the queue-owned held link is reused across ops - one connect, not one per op`() = runTest { + useTestDispatcher() + + // Queue opens the link once (connect-before-execute), then runs ops on it. + session.requestConnect(ADDRESS, "queue") + advanceUntilIdle() + assertThat(session.connected.value).isTrue() + + session.readInfusionInfo(ADDRESS) // op 1 on the held link + session.readInfusionInfo(ADDRESS) // op 2 on the held link — no re-dial + + // ONE connect total (the held link), not one per op — this removes #5003's second handshake. + assertThat(transport.connectAddresses).hasSize(1) + + session.requestDisconnect("queue empty") + advanceUntilIdle() + assertThat(session.connected.value).isFalse() + } + + @Test + fun `a would-be second-session handshake drop cannot happen on the held link`() = runTest { + useTestDispatcher() + // A drop scripted for a 2nd connect that never occurs: the held link makes exactly one connect, so + // both ops run on it and the second op succeeds where the transient path would have failed. + transport.dropConnectAtIndex = 2 + + session.requestConnect(ADDRESS, "queue") + advanceUntilIdle() + + session.readInfusionInfo(ADDRESS) + session.readInfusionInfo(ADDRESS) + + assertThat(transport.connectAddresses).hasSize(1) + } + + // ===== Held-link hardening (code-review fixes) ===== + + @Test + fun `a transient session never reports the held-link connected state`() = runTest { + useTestDispatcher() + val seen = mutableListOf() + val collector = launch { session.connected.collect { seen.add(it) } } + runCurrent() // collector subscribes and records the initial false + + session.readInfusionInfo(ADDRESS) // transient (no held link) + advanceUntilIdle() + collector.cancel() + + // `connected` tracks ONLY the queue-owned held link; a transient op must never flip it true, else + // isConnected() would let the queue skip connect() and re-dial per op (#5003 regression). + assertThat(seen).doesNotContain(true) + } + + @Test + fun `an unexpected drop on the held link tears it down so the queue re-dials`() = runTest { + useTestDispatcher() + session.requestConnect(ADDRESS, "queue") + advanceUntilIdle() + assertThat(session.connected.value).isTrue() + + // Patch drops out of range: the transport reports DISCONNECTED on the held link. + transport.capturedListener?.onConnectionStateChanged(false) + advanceUntilIdle() + + // watchForDrop tore the held link down → connected=false → the queue connect()s on its next command. + assertThat(session.connected.value).isFalse() + } + + @Test + fun `a failed op on the held link tears it down so the next command re-dials`() = runTest { + useTestDispatcher() + session.requestConnect(ADDRESS, "queue") + advanceUntilIdle() + assertThat(session.connected.value).isTrue() + + // The patch stops answering → the op times out on the (now suspect) held link. + transport.silentOpcodes.add(INFUSION_INFO_OPCODE) + assertFailsWith { session.readInfusionInfo(ADDRESS) } + + // Held link dropped rather than left up on a dead GATT → the next command re-dials. + assertThat(session.connected.value).isFalse() + } + + @Test + fun `pairing while a held link is up tears the held link down first`() = runTest { + useTestDispatcher() + session.requestConnect(ADDRESS, "queue") + advanceUntilIdle() + assertThat(session.connected.value).isTrue() + + // An ensureBond (pairing) op must not open a 2nd GATT alongside the held link on the single-slot + // transport — it closes the held link first, so pairing runs as the sole (transient) session. Pre-fix + // the held link would be orphaned (still up), leaving connected=true. + session.runPairing(ADDRESS, spec) + advanceUntilIdle() + + assertThat(session.connected.value).isFalse() + } + + // ===== Fixtures ===== + + /** + * Scripted patch: answers each opcode like a real CareLevo, with [emptySerialRounds] initial + * set-time rounds reporting a blank serial (13 spaces) before valid rounds. + * + * Every knob below defaults to the well-behaved patch, so a test opts into exactly the one + * misbehaviour it is about. + */ + private class FakePairingTransport : CarelevoBleTransport { + + var capturedListener: BleTransportListener? = null + val writes = mutableListOf() + var emptySerialRounds = 0 + var bonded = true + var createBondCalls = 0 + + /** Every address `gatt.connect` was dialed with, in order (one entry per session). */ + val connectAddresses = mutableListOf() + + /** `connect()` returns false — the BLE stack refused to start the connection. */ + var connectRefused = false + + /** `connect()` succeeds but the patch never reports STATE_CONNECTED. */ + var reportConnected = true + + /** + * 1-based index of a connect that succeeds at the BLE layer but never reaches STATE_CONNECTED — + * models a handshake drop on that specific session (e.g. the 2nd of two back-to-back sessions, + * PR #5003's re-dial race). null → every connect reports CONNECTED normally. + */ + var dropConnectAtIndex: Int? = null + + /** `createBond()` completes the SMP (flips [bonded]); false → the bond poll never resolves. */ + var bondOnCreate = true + + /** Opcodes the patch silently ignores — the caller's deadline is the only way out. */ + val silentOpcodes = mutableSetOf() + + /** Progress frames the safety check streams before its terminal frame. */ + var safetyCheckProgressFrames = 1 + + /** seqNo whose basal-program write is rejected (non-zero resultCode); null → all accepted. */ + var basalRejectAtSeq: Int? = null + + private var setTimeRounds = 0 + + override var scanAddress: String? = null + override var onGattError133: (() -> Unit)? = null + + override val adapter: BleAdapter = object : BleAdapter { + override fun enable() {} + override fun getDeviceName(address: String): String? = null + override fun isDeviceBonded(address: String): Boolean = bonded + override fun createBond(address: String): Boolean { + createBondCalls++ + if (bondOnCreate) bonded = true + return true + } + + override fun removeBond(address: String) {} + } + + override val scanner: BleScanner = object : BleScanner { + override val scannedDevices = MutableSharedFlow() + override fun startScan() {} + override fun stopScan() {} + } + + override val gatt: BleGatt = object : BleGatt { + override fun connect(address: String): Boolean { + connectAddresses += address + if (connectRefused) return false + // Succeed at the BLE layer but never report CONNECTED on the flagged connect → open() times out. + if (connectAddresses.size == dropConnectAtIndex) return true + if (reportConnected) capturedListener?.onConnectionStateChanged(true) + return true + } + + override fun disconnect() {} + override fun close() {} + override fun discoverServices() { + capturedListener?.onServicesDiscovered(true) + } + + override fun findCharacteristics(): Boolean = true + override fun enableNotifications() { + capturedListener?.onDescriptorWritten() + } + + override fun writeCharacteristic(data: ByteArray) { + writes += data + capturedListener?.onCharacteristicWritten() + respondTo(data) + } + } + + private fun respondTo(request: ByteArray) { + val listener = capturedListener ?: return + val opcode = request[0] + if (opcode in silentOpcodes) return + when (opcode) { + MAC_REQUEST_OPCODE -> listener.onCharacteristicChanged(byteArrayOf(MAC_RESPONSE_OPCODE) + MAC_BYTES + CHECKSUM_BYTE) + APP_AUTH_OPCODE -> listener.onCharacteristicChanged(byteArrayOf(APP_AUTH_ACK_OPCODE, RESULT_SUCCESS)) + SET_TIME_OPCODE -> { + setTimeRounds++ + val serial = if (setTimeRounds <= emptySerialRounds) BLANK_SERIAL else SERIAL + listener.onCharacteristicChanged(byteArrayOf(RPT1_OPCODE, RESULT_SUCCESS) + serial.toByteArray(Charsets.US_ASCII)) + listener.onCharacteristicChanged(RPT2_FRAME) + } + + ALERT_ALARM_OPCODE -> listener.onCharacteristicChanged(byteArrayOf(ALERT_ALARM_ACK_OPCODE, RESULT_SUCCESS)) + THRESHOLD_OPCODE -> listener.onCharacteristicChanged(byteArrayOf(THRESHOLD_ACK_OPCODE, RESULT_SUCCESS)) + INFUSION_INFO_OPCODE -> listener.onCharacteristicChanged(INFUSION_INFO_FRAME) + SAFETY_CHECK_OPCODE -> { + // Streams progress reports, then the terminal SUCCESS — all on 0x72. + repeat(safetyCheckProgressFrames) { listener.onCharacteristicChanged(safetyFrame(SAFETY_PROGRESS_RESULT.toByte())) } + listener.onCharacteristicChanged(safetyFrame(RESULT_SUCCESS)) + } + + BASAL_SET_OPCODE -> listener.onCharacteristicChanged(byteArrayOf(BASAL_SET_ACK_OPCODE, basalResultFor(request[1]))) + BASAL_UPDATE_OPCODE -> listener.onCharacteristicChanged(byteArrayOf(BASAL_UPDATE_ACK_OPCODE, basalResultFor(request[1]))) + } + } + + /** `[0] 0x72, [1] result, [2..3] volume 210 U, [4..5] duration 210 s`. */ + private fun safetyFrame(result: Byte) = byteArrayOf(SAFETY_CHECK_ACK_OPCODE, result, 0x02, 0x0A, 0x03, 0x1E) + + private fun basalResultFor(seqNo: Byte): Byte = + if (seqNo.toInt() == basalRejectAtSeq) BASAL_REJECTED else RESULT_SUCCESS + + private val _pairingState = MutableStateFlow(PairingState()) + override val pairingState = _pairingState + + override fun updatePairingState(state: PairingState) { + _pairingState.value = state + } + + override fun setListener(listener: BleTransportListener?) { + capturedListener = listener + } + } + + private companion object { + + const val ADDRESS = "94:B2:16:1D:2F:6D" + + // withSession/open budgets under test (mirrored from CarelevoBleSession's private companion). + const val CONNECT_TIMEOUT_MS = 20_000L + const val BOND_TIMEOUT_MS = 15_000L + const val INTER_SESSION_SETTLE_MS = 1000L + + // The settle is measured against the WALL clock while the test runs on virtual time, so the + // delay is (settle − real ms spent between the two sessions). Assert a band, not an equality. + const val SETTLE_LOWER_BOUND_MS = 500L + + const val MAC_REQUEST_OPCODE: Byte = 0x3B + const val MAC_RESPONSE_OPCODE: Byte = 0x9B.toByte() + const val APP_AUTH_OPCODE: Byte = 0x4B + const val APP_AUTH_ACK_OPCODE: Byte = 0xBB.toByte() + const val SET_TIME_OPCODE: Byte = 0x11 + const val RPT1_OPCODE: Byte = 0x93.toByte() + const val ALERT_ALARM_OPCODE: Byte = 0x48 + const val ALERT_ALARM_ACK_OPCODE: Byte = 0xA8.toByte() + const val THRESHOLD_OPCODE: Byte = 0x1B + const val THRESHOLD_ACK_OPCODE: Byte = 0x7B + const val RESULT_SUCCESS: Byte = 0x00 + + const val INFUSION_INFO_OPCODE: Byte = 0x31 + const val INFUSION_INFO_ACK_OPCODE: Byte = 0x91.toByte() + const val SAFETY_CHECK_OPCODE: Byte = 0x12 + const val SAFETY_CHECK_ACK_OPCODE: Byte = 0x72 + const val SAFETY_PROGRESS_RESULT = 4 // REP_REQUEST — a non-terminal progress report + const val BASAL_SET_OPCODE: Byte = 0x13 + const val BASAL_SET_ACK_OPCODE: Byte = 0x73 + const val BASAL_UPDATE_OPCODE: Byte = 0x21 + const val BASAL_UPDATE_ACK_OPCODE: Byte = 0x81.toByte() + const val BASAL_REJECTED: Byte = 0x05 + + // Real-shape 20-byte 0x91: running 2*60+30=150 min, remaining 1*100+50+50/100=150.5 U, + // basal total 10+25/100=10.25 U, bolus total 5+50/100=5.5 U, pumpState 2, mode 1. + val INFUSION_INFO_FRAME = byteArrayOf( + INFUSION_INFO_ACK_OPCODE, 0x00, + 0x02, 0x1E, + 0x01, 0x32, 0x32, + 0x0A, 0x19, + 0x05, 0x32, + 0x02, + 0x01, + 0x00, 0x00, + 0x03, 0x00, + 0x00, 0x01, 0x0A + ) + + val MAC_BYTES = byteArrayOf(0x94.toByte(), 0xB2.toByte(), 0x16, 0x1D, 0x2F, 0x6D) + const val CHECKSUM_BYTE: Byte = 0xAB.toByte() + + const val SERIAL = "EO12507099001" // 13 chars = RPT1 bytes 2..14 + const val BLANK_SERIAL = " " // 13 spaces → trims to empty → retry round + + // Real-shape 16-byte RPT2: [0]=0x94, [1]=result, [2..5]="T168", [6..10] filler, [11..15] model + // bytes 0x43,0x4C,0x33,0x30,0x30 → decimal-quirk string "6776514848" (matches the real device). + val RPT2_FRAME = byteArrayOf( + 0x94.toByte(), 0x00, + 0x54, 0x31, 0x36, 0x38, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x43, 0x4C, 0x33, 0x30, 0x30 + ) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/CarelevoBleTransportBondStateTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/CarelevoBleTransportBondStateTest.kt new file mode 100644 index 000000000000..c1a1ca662882 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/CarelevoBleTransportBondStateTest.kt @@ -0,0 +1,79 @@ +package app.aaps.pump.carelevo.ble + +import android.Manifest +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothManager +import android.content.Context +import app.aaps.core.interfaces.logging.AAPSLogger +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +/** + * Regression guard for issue #4990 (CareLevo pairing fails on Android 13). + * + * [CarelevoBleSession.open] polls [BleAdapter.isDeviceBonded] to wait for the SMP bond to **complete** + * before discovering services (the patch requires bond-then-discover). The predicate must therefore be + * true ONLY for `BOND_BONDED`: `BOND_BONDING` (11) also satisfies the old `!= BOND_NONE` check, which + * let discovery start mid-bonding and dropped the link on Android <= 13 + * (`GattDiscoveryException: disconnected`). Android 14+ happened to tolerate the early discovery, which + * is why the bug only surfaced on 13. + * + * The predicate is SDK-independent, so the SDK here only needs to be a version Robolectric provides; + * the Android-13 behaviour it guards against is the disconnect, not the bond-state mapping itself. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class CarelevoBleTransportBondStateTest { + + private val context: Context = RuntimeEnvironment.getApplication() + private val aapsLogger: AAPSLogger = mock() + private lateinit var transport: CarelevoBleTransportImpl + + @Before + fun setup() { + Shadows.shadowOf(RuntimeEnvironment.getApplication()).grantPermissions(Manifest.permission.BLUETOOTH_CONNECT) + transport = CarelevoBleTransportImpl(context, aapsLogger) + } + + private fun setBondState(state: Int) { + val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter + Shadows.shadowOf(adapter.getRemoteDevice(ADDRESS)).setBondState(state) + } + + @Test + fun `BOND_BONDED reports bonded`() { + setBondState(BluetoothDevice.BOND_BONDED) + assertThat(transport.adapter.isDeviceBonded(ADDRESS)).isTrue() + } + + @Test + fun `BOND_BONDING is not yet bonded so discovery waits`() { + setBondState(BluetoothDevice.BOND_BONDING) + assertThat(transport.adapter.isDeviceBonded(ADDRESS)).isFalse() + } + + @Test + fun `BOND_NONE reports not bonded`() { + setBondState(BluetoothDevice.BOND_NONE) + assertThat(transport.adapter.isDeviceBonded(ADDRESS)).isFalse() + } + + @Test + fun `missing BLUETOOTH_CONNECT permission reports not bonded`() { + Shadows.shadowOf(RuntimeEnvironment.getApplication()).denyPermissions(Manifest.permission.BLUETOOTH_CONNECT) + setBondState(BluetoothDevice.BOND_BONDED) + assertThat(transport.adapter.isDeviceBonded(ADDRESS)).isFalse() + } + + private companion object { + + const val ADDRESS = "94:B2:16:1D:40:5C" + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/AdditionalPrimingCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/AdditionalPrimingCommandTest.kt new file mode 100644 index 000000000000..6038d79babec --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/AdditionalPrimingCommandTest.kt @@ -0,0 +1,34 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class AdditionalPrimingCommandTest { + + @Test + fun `encode is single opcode byte 0x1D`() { + assertThat(AdditionalPrimingCommand().encode().toList()) + .containsExactly(0x1D.toByte()).inOrder() + } + + @Test + fun `decode returns result code`() { + assertThat(AdditionalPrimingCommand().decode(byteArrayOf(0x7D, 0x00)).resultCode).isEqualTo(0) + } + + @Test + fun `decode non-zero result code`() { + assertThat(AdditionalPrimingCommand().decode(byteArrayOf(0x7D, 0x05)).resultCode).isEqualTo(5) + } + + @Test + fun `decode wrong opcode throws`() { + assertFailsWith { AdditionalPrimingCommand().decode(byteArrayOf(0x7C, 0x00)) } + } + + @Test + fun `decode too short throws`() { + assertFailsWith { AdditionalPrimingCommand().decode(byteArrayOf(0x7D)) } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/AlarmClearCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/AlarmClearCommandTest.kt new file mode 100644 index 000000000000..d965683f6302 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/AlarmClearCommandTest.kt @@ -0,0 +1,52 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class AlarmClearCommandTest { + + @Test + fun `encode alarmType raw and cause`() { + assertThat(AlarmClearCommand(alarmType = 3, cause = 50).encode().toList()) + .containsExactly(0x47.toByte(), 3.toByte(), 50.toByte()).inOrder() + } + + @Test + fun `encode passes alarmType through raw above 0x7F`() { + assertThat(AlarmClearCommand(alarmType = 0x80, cause = 0).encode().toList()) + .containsExactly(0x47.toByte(), 0x80.toByte(), 0.toByte()).inOrder() + } + + @Test + fun `decode maps subId cause result`() { + val r = AlarmClearCommand(alarmType = 3, cause = 50).decode(byteArrayOf(0xA7.toByte(), 0x01, 0x02, 0x00)) + assertThat(r.subId).isEqualTo(1) + assertThat(r.cause).isEqualTo(2) + assertThat(r.resultCode).isEqualTo(0) + } + + @Test + fun `cause above range throws`() { + assertFailsWith { AlarmClearCommand(alarmType = 3, cause = 101) } + } + + @Test + fun `cause below range throws`() { + assertFailsWith { AlarmClearCommand(alarmType = 3, cause = -1) } + } + + @Test + fun `decode wrong opcode throws`() { + assertFailsWith { + AlarmClearCommand(alarmType = 3, cause = 50).decode(byteArrayOf(0x99.toByte(), 0x01, 0x02, 0x00)) + } + } + + @Test + fun `decode too short throws`() { + assertFailsWith { + AlarmClearCommand(alarmType = 3, cause = 50).decode(byteArrayOf(0xA7.toByte(), 0x01, 0x02)) + } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/AlertAlarmSetCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/AlertAlarmSetCommandTest.kt new file mode 100644 index 000000000000..5905ae8bc1e2 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/AlertAlarmSetCommandTest.kt @@ -0,0 +1,46 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class AlertAlarmSetCommandTest { + + @Test + fun `encode mode=0 is 0x48 0x00`() { + assertThat(AlertAlarmSetCommand(mode = 0).encode().toList()) + .containsExactly(0x48.toByte(), 0x00.toByte()).inOrder() + } + + @Test + fun `encode mode=1 is 0x48 0x01 (raw byte)`() { + assertThat(AlertAlarmSetCommand(mode = 1).encode().toList()) + .containsExactly(0x48.toByte(), 0x01.toByte()).inOrder() + } + + @Test + fun `encode high mode wraps to one byte (no range check)`() { + // No bounds check: the mode is emitted as a raw mode.toByte(), so values > 0x7F wrap. + assertThat(AlertAlarmSetCommand(mode = 200).encode().toList()) + .containsExactly(0x48.toByte(), 200.toByte()).inOrder() + } + + @Test + fun `decode returns result code`() { + assertThat(AlertAlarmSetCommand(mode = 0).decode(byteArrayOf(0xA8.toByte(), 0x00)).resultCode).isEqualTo(0) + } + + @Test + fun `decode wrong opcode throws`() { + assertFailsWith { + AlertAlarmSetCommand(mode = 0).decode(byteArrayOf(0x78, 0x00)) + } + } + + @Test + fun `decode too short throws`() { + assertFailsWith { + AlertAlarmSetCommand(mode = 0).decode(byteArrayOf(0xA8.toByte())) + } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/AppAuthCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/AppAuthCommandTest.kt new file mode 100644 index 000000000000..32122cbd1dee --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/AppAuthCommandTest.kt @@ -0,0 +1,56 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class AppAuthCommandTest { + + @Test + fun `encode is opcode 0x4B then raw key byte`() { + assertThat(AppAuthCommand(key = 0x2A).encode().toList()) + .containsExactly(0x4B.toByte(), 0x2A.toByte()).inOrder() + } + + @Test + fun `encode carries high key value as raw byte`() { + assertThat(AppAuthCommand(key = 255).encode().toList()) + .containsExactly(0x4B.toByte(), 0xFF.toByte()).inOrder() + } + + @Test + fun `decode returns result code from byte 1`() { + val r = AppAuthCommand(key = 1).decode(byteArrayOf(0xBB.toByte(), 0x00)) + assertThat(r.resultCode).isEqualTo(0) + } + + @Test + fun `decode reads non-zero result unsigned`() { + val r = AppAuthCommand(key = 1).decode(byteArrayOf(0xBB.toByte(), 0x80.toByte())) + assertThat(r.resultCode).isEqualTo(128) + } + + @Test + fun `key below range throws`() { + assertFailsWith { AppAuthCommand(key = -1) } + } + + @Test + fun `key above range throws`() { + assertFailsWith { AppAuthCommand(key = 256) } + } + + @Test + fun `decode wrong opcode throws`() { + assertFailsWith { + AppAuthCommand(key = 1).decode(byteArrayOf(0x4B, 0x00)) + } + } + + @Test + fun `decode too short throws`() { + assertFailsWith { + AppAuthCommand(key = 1).decode(byteArrayOf(0xBB.toByte())) + } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/BasalProgramCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/BasalProgramCommandTest.kt new file mode 100644 index 000000000000..5582222d3501 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/BasalProgramCommandTest.kt @@ -0,0 +1,48 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class BasalProgramCommandTest { + + @Test + fun `encode set is 0x13 seqNo then 2-byte segments`() { + val bytes = BasalProgramCommand(isUpdate = false, seqNo = 0, segmentSpeeds = listOf(1.05, 1.3335)).encode().toList() + // 1.05 -> [1,5]; 1.3335 HALF_UP -> 1.33 -> [1,33] + assertThat(bytes).containsExactly( + 0x13.toByte(), 0x00.toByte(), 1.toByte(), 5.toByte(), 1.toByte(), 33.toByte() + ).inOrder() + } + + @Test + fun `encode update uses 0x21 opcode`() { + val bytes = BasalProgramCommand(isUpdate = true, seqNo = 1, segmentSpeeds = listOf(2.0)).encode().toList() + assertThat(bytes).containsExactly(0x21.toByte(), 0x01.toByte(), 2.toByte(), 0.toByte()).inOrder() + } + + @Test + fun `decode set response 0x73`() { + assertThat(BasalProgramCommand(false, 0, listOf(1.0)).decode(byteArrayOf(0x73, 0x00)).resultCode).isEqualTo(0) + } + + @Test + fun `decode update response 0x81`() { + assertThat(BasalProgramCommand(true, 0, listOf(1.0)).decode(byteArrayOf(0x81.toByte(), 0x00)).resultCode).isEqualTo(0) + } + + @Test + fun `seqNo out of range throws`() { + assertFailsWith { BasalProgramCommand(false, 3, listOf(1.0)) } + } + + @Test + fun `empty segments throws`() { + assertFailsWith { BasalProgramCommand(false, 0, emptyList()) } + } + + @Test + fun `set command rejects update response opcode`() { + assertFailsWith { BasalProgramCommand(false, 0, listOf(1.0)).decode(byteArrayOf(0x81.toByte(), 0x00)) } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/BolusCancelCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/BolusCancelCommandTest.kt new file mode 100644 index 000000000000..12dd15055520 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/BolusCancelCommandTest.kt @@ -0,0 +1,42 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class BolusCancelCommandTest { + + @Test + fun `encode is just the opcode 0x2C`() { + assertThat(BolusCancelCommand().encode().toList()) + .containsExactly(0x2C.toByte()).inOrder() + } + + @Test + fun `decode returns result and infused amount`() { + val r = BolusCancelCommand().decode(byteArrayOf(0x8C.toByte(), 0x00, 3.toByte(), 25.toByte())) + assertThat(r.resultCode).isEqualTo(0) + assertThat(r.infusedAmount).isEqualTo(3.25) + } + + @Test + fun `decode reads unsigned bytes`() { + val r = BolusCancelCommand().decode(byteArrayOf(0x8C.toByte(), 0xFF.toByte(), 200.toByte(), 99.toByte())) + assertThat(r.resultCode).isEqualTo(255) + assertThat(r.infusedAmount).isEqualTo(200.99) + } + + @Test + fun `decode wrong opcode throws`() { + assertFailsWith { + BolusCancelCommand().decode(byteArrayOf(0x8B.toByte(), 0x00, 0x00, 0x00)) + } + } + + @Test + fun `decode too short throws`() { + assertFailsWith { + BolusCancelCommand().decode(byteArrayOf(0x8C.toByte(), 0x00, 0x00)) + } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/BuzzModeCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/BuzzModeCommandTest.kt new file mode 100644 index 000000000000..9aa4a7e3c6fc --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/BuzzModeCommandTest.kt @@ -0,0 +1,30 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class BuzzModeCommandTest { + + @Test + fun `encode use=true is 0x18 0x01 (double inversion)`() { + assertThat(BuzzModeCommand(use = true).encode().toList()) + .containsExactly(0x18.toByte(), 0x01.toByte()).inOrder() + } + + @Test + fun `encode use=false is 0x18 0x00`() { + assertThat(BuzzModeCommand(use = false).encode().toList()) + .containsExactly(0x18.toByte(), 0x00.toByte()).inOrder() + } + + @Test + fun `decode returns result code`() { + assertThat(BuzzModeCommand(use = true).decode(byteArrayOf(0x78, 0x00)).resultCode).isEqualTo(0) + } + + @Test + fun `decode wrong opcode throws`() { + assertFailsWith { BuzzModeCommand(use = true).decode(byteArrayOf(0x79, 0x00)) } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/ExtendBolusCancelCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/ExtendBolusCancelCommandTest.kt new file mode 100644 index 000000000000..631e2e291071 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/ExtendBolusCancelCommandTest.kt @@ -0,0 +1,48 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class ExtendBolusCancelCommandTest { + + @Test + fun `encode is just opcode 0x29`() { + assertThat(ExtendBolusCancelCommand().encode().toList()) + .containsExactly(0x29.toByte()).inOrder() + } + + @Test + fun `decode returns result and infused amount`() { + val r = ExtendBolusCancelCommand().decode(byteArrayOf(0x89.toByte(), 0x00, 2.toByte(), 50.toByte())) + assertThat(r.resultCode).isEqualTo(0) + assertThat(r.infusedAmount).isEqualTo(2.5) + } + + @Test + fun `decode reads centi hundredths byte`() { + val r = ExtendBolusCancelCommand().decode(byteArrayOf(0x89.toByte(), 0x01, 0.toByte(), 5.toByte())) + assertThat(r.resultCode).isEqualTo(1) + assertThat(r.infusedAmount).isEqualTo(0.05) + } + + @Test + fun `decode reads high unsigned bytes`() { + val r = ExtendBolusCancelCommand().decode(byteArrayOf(0x89.toByte(), 0x00, 200.toByte(), 99.toByte())) + assertThat(r.infusedAmount).isEqualTo(200.99) + } + + @Test + fun `decode wrong opcode throws`() { + assertFailsWith { + ExtendBolusCancelCommand().decode(byteArrayOf(0x88.toByte(), 0x00, 0x00, 0x00)) + } + } + + @Test + fun `decode too short throws`() { + assertFailsWith { + ExtendBolusCancelCommand().decode(byteArrayOf(0x89.toByte(), 0x00, 0x00)) + } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/ExtendBolusCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/ExtendBolusCommandTest.kt new file mode 100644 index 000000000000..587460c7f7c7 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/ExtendBolusCommandTest.kt @@ -0,0 +1,62 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class ExtendBolusCommandTest { + + @Test + fun `encode immediate and extended doses as unit-centi plus hour and min`() { + assertThat(ExtendBolusCommand(immediateDose = 1.5, extendedSpeed = 0.75, hour = 2, min = 30).encode().toList()) + .containsExactly( + 0x25.toByte(), 1.toByte(), 50.toByte(), 0.toByte(), 75.toByte(), 2.toByte(), 30.toByte() + ).inOrder() + } + + @Test + fun `encode rounds fractional doses to centi`() { + assertThat(ExtendBolusCommand(immediateDose = 0.05, extendedSpeed = 12.34, hour = 0, min = 0).encode().toList()) + .containsExactly( + 0x25.toByte(), 0.toByte(), 5.toByte(), 12.toByte(), 34.toByte(), 0.toByte(), 0.toByte() + ).inOrder() + } + + @Test + fun `decode returns result and expected time in seconds`() { + val r = ExtendBolusCommand(immediateDose = 1.0, extendedSpeed = 1.0, hour = 1, min = 0) + .decode(byteArrayOf(0x85.toByte(), 0x00, 0x03, 0x14)) + assertThat(r.resultCode).isEqualTo(0) + assertThat(r.expectedTimeSeconds).isEqualTo(200) // 3*60 + 20 + } + + @Test + fun `hour above range throws`() { + assertFailsWith { + ExtendBolusCommand(immediateDose = 1.0, extendedSpeed = 1.0, hour = 25, min = 0) + } + } + + @Test + fun `min above range throws`() { + assertFailsWith { + ExtendBolusCommand(immediateDose = 1.0, extendedSpeed = 1.0, hour = 0, min = 60) + } + } + + @Test + fun `decode wrong opcode throws`() { + assertFailsWith { + ExtendBolusCommand(immediateDose = 1.0, extendedSpeed = 1.0, hour = 1, min = 0) + .decode(byteArrayOf(0x86.toByte(), 0x00, 0x00, 0x00)) + } + } + + @Test + fun `decode too short throws`() { + assertFailsWith { + ExtendBolusCommand(immediateDose = 1.0, extendedSpeed = 1.0, hour = 1, min = 0) + .decode(byteArrayOf(0x85.toByte(), 0x00, 0x00)) + } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/ImmediateBolusCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/ImmediateBolusCommandTest.kt new file mode 100644 index 000000000000..e49908526165 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/ImmediateBolusCommandTest.kt @@ -0,0 +1,189 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleClientImpl +import app.aaps.pump.carelevo.ble.gatt.FakeGattConnection +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.Test +import java.util.UUID +import kotlin.test.assertFailsWith + +/** + * Tests for [ImmediateBolusCommand]. Safety-critical — wrong encoding = wrong dose. + * + * Coverage: + * - Encode layout for typical + boundary volumes (2.50 U, 0.05 U, 0.33 U rounding) + * - Response decoding with all six fields populated + * - Input validation (actionId range, positive volume) + * - Opcode mismatch and truncation rejection + * - End-to-end round trip through [BleClientImpl] with correct actionId echo + * - End-to-end **wrong** actionId echo → correlation rejects → request times out + * (proves the safety property: a stale BOLUS_RES never completes this request) + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class ImmediateBolusCommandTest { + + @Test + fun `encode 2_50 U with actionId 42 produces opcode + id + 2 + 50`() { + val cmd = ImmediateBolusCommand(actionId = 42, volume = 2.50) + assertThat(cmd.encode()).isEqualTo(byteArrayOf(0x24, 0x2A, 0x02, 0x32)) + } + + @Test + fun `encode 0_05 U with actionId 1 produces opcode + 1 + 0 + 5`() { + val cmd = ImmediateBolusCommand(actionId = 1, volume = 0.05) + assertThat(cmd.encode()).isEqualTo(byteArrayOf(0x24, 0x01, 0x00, 0x05)) + } + + @Test + fun `encode rounds 0_333 U to 0_33 U HALF_UP`() { + val cmd = ImmediateBolusCommand(actionId = 1, volume = 0.333) + assertThat(cmd.encode()).isEqualTo(byteArrayOf(0x24, 0x01, 0x00, 0x21)) // 0x21 = 33 + } + + @Test + fun `encode rounds 0_005 U to 0_01 U HALF_UP`() { + val cmd = ImmediateBolusCommand(actionId = 1, volume = 0.005) + assertThat(cmd.encode()).isEqualTo(byteArrayOf(0x24, 0x01, 0x00, 0x01)) + } + + @Test + fun `encode actionId 200 becomes signed byte -56 preserving bit pattern`() { + val cmd = ImmediateBolusCommand(actionId = 200, volume = 1.00) + // 200 decimal = 0xC8 which is -56 as signed byte + assertThat(cmd.encode()[1]).isEqualTo(0xC8.toByte()) + } + + @Test + fun `constructor rejects actionId 0`() { + assertFailsWith { + ImmediateBolusCommand(actionId = 0, volume = 1.0) + } + } + + @Test + fun `constructor rejects actionId 256`() { + assertFailsWith { + ImmediateBolusCommand(actionId = 256, volume = 1.0) + } + } + + @Test + fun `constructor rejects zero volume`() { + assertFailsWith { + ImmediateBolusCommand(actionId = 1, volume = 0.0) + } + } + + @Test + fun `constructor rejects negative volume`() { + assertFailsWith { + ImmediateBolusCommand(actionId = 1, volume = -0.1) + } + } + + @Test + fun `decode extracts all fields from canonical response`() { + val cmd = ImmediateBolusCommand(actionId = 42, volume = 2.50) + // opcode, actionId=42, result=0, minutes=2, seconds=30, remains = 1*100 + 50 + 25/100 = 150.25 U + val payload = byteArrayOf( + 0x84.toByte(), + 0x2A, + 0x00, + 0x02, 0x1E, // 2 min + 30 s = 150 s + 0x01, 0x32, 0x19 // 1, 50, 25 + ) + + val response = cmd.decode(payload) + + assertThat(response.actionId).isEqualTo(42) + assertThat(response.resultCode).isEqualTo(0) + assertThat(response.expectedCompletionSeconds).isEqualTo(150) + assertThat(response.remainingReservoirUnits).isEqualTo(150.25) + } + + @Test + fun `decode unsigned-byte values above 127`() { + val cmd = ImmediateBolusCommand(actionId = 200, volume = 1.0) + val payload = byteArrayOf( + 0x84.toByte(), + 0xC8.toByte(), // actionId 200 + 0xFF.toByte(), // resultCode 255 + 0x00, 0x00, + 0x00, 0x00, 0x00 + ) + + val response = cmd.decode(payload) + + assertThat(response.actionId).isEqualTo(200) + assertThat(response.resultCode).isEqualTo(255) + } + + @Test + fun `decode rejects wrong opcode`() { + val cmd = ImmediateBolusCommand(actionId = 1, volume = 1.0) + val wrong = byteArrayOf(0x85.toByte(), 0x01, 0, 0, 0, 0, 0, 0) + + assertFailsWith { cmd.decode(wrong) } + } + + @Test + fun `decode rejects truncated payload`() { + val cmd = ImmediateBolusCommand(actionId = 1, volume = 1.0) + val short = byteArrayOf(0x84.toByte(), 0x01, 0x00) + + assertFailsWith { cmd.decode(short) } + } + + @Test + fun `end to end - BleClient correlates response by actionId echo`() = runTest { + val writeUuid = UUID.fromString("00000001-0000-1000-8000-00805f9b34fb") + val notifyUuid = UUID.fromString("00000002-0000-1000-8000-00805f9b34fb") + val gatt = FakeGattConnection() + val client = BleClientImpl(gatt, writeUuid, notifyUuid, backgroundScope) + runCurrent() + + gatt.onNextWrite { write -> + assertThat(write.payload).isEqualTo(byteArrayOf(0x24, 0x2A, 0x02, 0x32)) + // Echo the correct actionId back. + gatt.deliverNotification( + notifyUuid, + byteArrayOf(0x84.toByte(), 0x2A, 0x00, 0x02, 0x1E, 0x01, 0x32, 0x19) + ) + } + + val response = client.request(ImmediateBolusCommand(actionId = 42, volume = 2.50)) + + assertThat(response.actionId).isEqualTo(42) + assertThat(response.resultCode).isEqualTo(0) + assertThat(response.remainingReservoirUnits).isEqualTo(150.25) + } + + @Test + fun `end to end - wrong actionId echo is rejected and request times out`() = runTest { + val writeUuid = UUID.fromString("00000001-0000-1000-8000-00805f9b34fb") + val notifyUuid = UUID.fromString("00000002-0000-1000-8000-00805f9b34fb") + val gatt = FakeGattConnection() + val client = BleClientImpl(gatt, writeUuid, notifyUuid, backgroundScope) + runCurrent() + + gatt.onNextWrite { + // Response carries actionId 99, but the request sent 42. + // BleClient must reject this as uncorrelated. + gatt.deliverNotification( + notifyUuid, + byteArrayOf(0x84.toByte(), 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + ) + } + + assertFailsWith { + withTimeout(500) { + client.request(ImmediateBolusCommand(actionId = 42, volume = 2.50)) + } + } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/InfusionInfoCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/InfusionInfoCommandTest.kt new file mode 100644 index 000000000000..b0a1fdad2653 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/InfusionInfoCommandTest.kt @@ -0,0 +1,74 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +/** + * Verifies [InfusionInfoCommand] decodes the 0x91 frame: running time, reservoir with the ×100 + * hundreds byte, basal/bolus totals, raw pumpState/mode. + */ +internal class InfusionInfoCommandTest { + + /** A 20-byte 0x91 frame from the given field bytes. */ + private fun frame( + subId: Int = 0, + runningHour: Int = 1, runningMin: Int = 30, + insHundreds: Int = 2, insInt: Int = 5, insDec: Int = 50, + basalInt: Int = 3, basalDec: Int = 25, + bolusInt: Int = 1, bolusDec: Int = 75, + pumpState: Int = 1, mode: Int = 1, + setHour: Int = 0, setMin: Int = 0, + curInt: Int = 0, curDec: Int = 0, + realHour: Int = 0, realMin: Int = 0, realSec: Int = 10 + ): ByteArray = intArrayOf( + 0x91, subId, runningHour, runningMin, insHundreds, insInt, insDec, + basalInt, basalDec, bolusInt, bolusDec, pumpState, mode, + setHour, setMin, curInt, curDec, realHour, realMin, realSec + ).map { it.toByte() }.toByteArray() + + @Test + fun `encode is 0x31 plus inquiryType 0`() { + assertThat(InfusionInfoCommand().encode().toList()).containsExactly(0x31.toByte(), 0x00.toByte()).inOrder() + } + + @Test + fun `decode maps running time reservoir totals and raw state`() { + val r = InfusionInfoCommand().decode(frame()) + + assertThat(r.runningMinutes).isEqualTo(90) // 1*60 + 30 + assertThat(r.insulinRemaining).isEqualTo(205.5) // 2*100 + 5 + 50/100 + assertThat(r.infusedTotalBasalAmount).isEqualTo(3.25) + assertThat(r.infusedTotalBolusAmount).isEqualTo(1.75) + assertThat(r.pumpStateRaw).isEqualTo(1) + assertThat(r.modeRaw).isEqualTo(1) + assertThat(r.realInfusedTime).isEqualTo(10) // (0*60 + 0)*60 + 10 + } + + @Test + fun `decode reads high byte values as unsigned`() { + // 200 U reservoir (2*100), mode byte 0xFF must decode to 255 (raw), not -1. + val r = InfusionInfoCommand().decode(frame(insHundreds = 2, insInt = 0, insDec = 0, mode = 0xFF, pumpState = 0x80)) + assertThat(r.insulinRemaining).isEqualTo(200.0) + assertThat(r.modeRaw).isEqualTo(255) + assertThat(r.pumpStateRaw).isEqualTo(128) + } + + @Test + fun `decode throws on wrong opcode`() { + val bad = frame().also { it[0] = 0x92.toByte() } + assertFailsWith { InfusionInfoCommand().decode(bad) } + } + + @Test + fun `decode throws when frame is shorter than 20 bytes`() { + assertFailsWith { + InfusionInfoCommand().decode(frame().copyOf(19)) + } + } + + @Test + fun `constructor rejects out-of-range inquiryType`() { + assertFailsWith { InfusionInfoCommand(inquiryType = 2) } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/InfusionThresholdCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/InfusionThresholdCommandTest.kt new file mode 100644 index 000000000000..a6e3553d9c78 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/InfusionThresholdCommandTest.kt @@ -0,0 +1,55 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class InfusionThresholdCommandTest { + + @Test + fun `encode max-speed flag 0x00 with unit-centi value`() { + assertThat(InfusionThresholdCommand(isMaxVolume = false, value = 2.5).encode().toList()) + .containsExactly(0x17.toByte(), 0x00.toByte(), 2.toByte(), 50.toByte()).inOrder() + } + + @Test + fun `encode max-volume flag 0x01`() { + assertThat(InfusionThresholdCommand(isMaxVolume = true, value = 15.0).encode().toList()) + .containsExactly(0x17.toByte(), 0x01.toByte(), 15.toByte(), 0.toByte()).inOrder() + } + + @Test + fun `encode rounds fractional to centi`() { + assertThat(InfusionThresholdCommand(isMaxVolume = false, value = 0.05).encode().toList()) + .containsExactly(0x17.toByte(), 0x00.toByte(), 0.toByte(), 5.toByte()).inOrder() + } + + @Test + fun `decode returns type and result`() { + val r = InfusionThresholdCommand(isMaxVolume = true, value = 15.0).decode(byteArrayOf(0x77, 0x01, 0x00)) + assertThat(r.type).isEqualTo(1) + assertThat(r.resultCode).isEqualTo(0) + } + + @Test + fun `max-speed above range throws`() { + assertFailsWith { InfusionThresholdCommand(isMaxVolume = false, value = 20.0) } + } + + @Test + fun `max-volume above range throws`() { + assertFailsWith { InfusionThresholdCommand(isMaxVolume = true, value = 30.0) } + } + + @Test + fun `below minimum throws`() { + assertFailsWith { InfusionThresholdCommand(isMaxVolume = false, value = 0.0) } + } + + @Test + fun `decode too short throws`() { + assertFailsWith { + InfusionThresholdCommand(isMaxVolume = true, value = 15.0).decode(byteArrayOf(0x77, 0x01)) + } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/MacAddressCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/MacAddressCommandTest.kt new file mode 100644 index 000000000000..c2c9cf83bdcc --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/MacAddressCommandTest.kt @@ -0,0 +1,96 @@ +package app.aaps.pump.carelevo.ble.commands + +import app.aaps.pump.carelevo.ble.BleClientImpl +import app.aaps.pump.carelevo.ble.gatt.FakeGattConnection +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.util.UUID +import kotlin.test.assertFailsWith + +/** + * Unit tests for [MacAddressCommand] — pure encode/decode plus one end-to-end round-trip + * through [BleClientImpl] + [FakeGattConnection] to prove the whole stack works together + * for a real opcode pair. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class MacAddressCommandTest { + + @Test + fun `encode produces 0x3B followed by the key byte`() { + val cmd = MacAddressCommand(key = 0x7A) + val encoded = cmd.encode() + + assertThat(encoded).isEqualTo(byteArrayOf(0x3B, 0x7A)) + } + + @Test + fun `decode extracts mac address and checksum as hex strings`() { + val cmd = MacAddressCommand(key = 0x01) + // Opcode + 6 MAC bytes + 2 checksum bytes + val payload = byteArrayOf( + 0x9B.toByte(), + 0xAA.toByte(), 0xBB.toByte(), 0xCC.toByte(), 0xDD.toByte(), 0xEE.toByte(), 0xFF.toByte(), + 0x12, 0x34 + ) + + val response = cmd.decode(payload) + + assertThat(response.macAddress).isEqualTo("AABBCCDDEEFF") + assertThat(response.checkSum).isEqualTo("1234") + } + + @Test + fun `decode rejects wrong opcode`() { + val cmd = MacAddressCommand(key = 0x01) + val wrongOpcode = byteArrayOf( + 0x84.toByte(), // NOT 0x9B + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07 + ) + + assertFailsWith { + cmd.decode(wrongOpcode) + } + } + + @Test + fun `decode rejects truncated payload`() { + val cmd = MacAddressCommand(key = 0x01) + val tooShort = byteArrayOf(0x9B.toByte(), 0x01, 0x02, 0x03) + + assertFailsWith { + cmd.decode(tooShort) + } + } + + @Test + fun `end to end - BleClient sends request and decodes response from FakeGatt`() = runTest { + val writeUuid = UUID.fromString("00000001-0000-1000-8000-00805f9b34fb") + val notifyUuid = UUID.fromString("00000002-0000-1000-8000-00805f9b34fb") + val gatt = FakeGattConnection() + val client = BleClientImpl(gatt, writeUuid, notifyUuid, backgroundScope) + runCurrent() // ensure the event collector is attached + + // Script the peripheral: when the SUT writes 0x3B, reply with 0x9B + MAC + checksum. + gatt.onNextWrite { write -> + assertThat(write.payload[0]).isEqualTo(0x3B.toByte()) + gatt.deliverNotification( + notifyUuid, + byteArrayOf( + 0x9B.toByte(), + 0x94.toByte(), 0xB2.toByte(), 0x16, 0x1D, 0x2F, 0x6D, + 0xAB.toByte(), 0xCD.toByte() + ) + ) + } + + val response = client.request(MacAddressCommand(key = 0x42)) + + assertThat(response.macAddress).isEqualTo("94B2161D2F6D") + assertThat(response.checkSum).isEqualTo("ABCD") + assertThat(gatt.recordedWrites).hasSize(1) + assertThat(gatt.recordedWrites.single().payload).isEqualTo(byteArrayOf(0x3B, 0x42)) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/NeedleAckCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/NeedleAckCommandTest.kt new file mode 100644 index 000000000000..bbf30313a6cb --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/NeedleAckCommandTest.kt @@ -0,0 +1,28 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class NeedleAckCommandTest { + + @Test + fun `encode success is 0x19 0x00`() { + assertThat(NeedleAckCommand(isSuccess = true).encode().toList()).containsExactly(0x19.toByte(), 0x00.toByte()).inOrder() + } + + @Test + fun `encode failure is 0x19 0x01`() { + assertThat(NeedleAckCommand(isSuccess = false).encode().toList()).containsExactly(0x19.toByte(), 0x01.toByte()).inOrder() + } + + @Test + fun `decode reads result from 0x7A response`() { + assertThat(NeedleAckCommand(isSuccess = true).decode(byteArrayOf(0x7A, 0x00)).resultCode).isEqualTo(0) + } + + @Test + fun `decode wrong opcode throws`() { + assertFailsWith { NeedleAckCommand(isSuccess = true).decode(byteArrayOf(0x79, 0x00)) } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/NeedleStatusCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/NeedleStatusCommandTest.kt new file mode 100644 index 000000000000..cf74b713364d --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/NeedleStatusCommandTest.kt @@ -0,0 +1,28 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class NeedleStatusCommandTest { + + @Test + fun `encode is 0x1A 0x00`() { + assertThat(NeedleStatusCommand().encode().toList()).containsExactly(0x1A.toByte(), 0x00.toByte()).inOrder() + } + + @Test + fun `decode reads result from asymmetric 0x79 response`() { + assertThat(NeedleStatusCommand().decode(byteArrayOf(0x79, 0x00)).resultCode).isEqualTo(0) + } + + @Test + fun `decode wrong opcode throws`() { + assertFailsWith { NeedleStatusCommand().decode(byteArrayOf(0x7A, 0x00)) } + } + + @Test + fun `decode too short throws`() { + assertFailsWith { NeedleStatusCommand().decode(byteArrayOf(0x79)) } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/NoticeThresholdCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/NoticeThresholdCommandTest.kt new file mode 100644 index 000000000000..6c585988077c --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/NoticeThresholdCommandTest.kt @@ -0,0 +1,49 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class NoticeThresholdCommandTest { + + @Test + fun `encode low-insulin type is 0x15 type value`() { + assertThat(NoticeThresholdCommand(NoticeThresholdCommand.TYPE_LOW_INSULIN, 30).encode().toList()) + .containsExactly(0x15.toByte(), 0x00.toByte(), 30.toByte()).inOrder() + } + + @Test + fun `encode expiry type is 0x15 type value`() { + assertThat(NoticeThresholdCommand(NoticeThresholdCommand.TYPE_EXPIRY, 100).encode().toList()) + .containsExactly(0x15.toByte(), 0x01.toByte(), 100.toByte()).inOrder() + } + + @Test + fun `decode returns echoed type and fabricated result 0`() { + val r = NoticeThresholdCommand(NoticeThresholdCommand.TYPE_EXPIRY, 100).decode(byteArrayOf(0x75, 0x01)) + assertThat(r.thresholdType).isEqualTo(1) + assertThat(r.resultCode).isEqualTo(0) + } + + @Test + fun `low-insulin value out of range throws`() { + assertFailsWith { NoticeThresholdCommand(NoticeThresholdCommand.TYPE_LOW_INSULIN, 60) } + } + + @Test + fun `expiry value out of range throws`() { + assertFailsWith { NoticeThresholdCommand(NoticeThresholdCommand.TYPE_EXPIRY, 200) } + } + + @Test + fun `bad type throws`() { + assertFailsWith { NoticeThresholdCommand(2, 30) } + } + + @Test + fun `decode wrong opcode throws`() { + assertFailsWith { + NoticeThresholdCommand(NoticeThresholdCommand.TYPE_EXPIRY, 100).decode(byteArrayOf(0x76, 0x01)) + } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/PatchDiscardCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/PatchDiscardCommandTest.kt new file mode 100644 index 000000000000..8c1e9fea9eff --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/PatchDiscardCommandTest.kt @@ -0,0 +1,34 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class PatchDiscardCommandTest { + + @Test + fun `encode is just 0x36`() { + assertThat(PatchDiscardCommand().encode().toList()) + .containsExactly(0x36.toByte()).inOrder() + } + + @Test + fun `decode returns result code`() { + assertThat(PatchDiscardCommand().decode(byteArrayOf(0x96.toByte(), 0x00)).resultCode).isEqualTo(0) + } + + @Test + fun `decode non-zero result code`() { + assertThat(PatchDiscardCommand().decode(byteArrayOf(0x96.toByte(), 0x05)).resultCode).isEqualTo(5) + } + + @Test + fun `decode wrong opcode throws`() { + assertFailsWith { PatchDiscardCommand().decode(byteArrayOf(0x95.toByte(), 0x00)) } + } + + @Test + fun `decode too short throws`() { + assertFailsWith { PatchDiscardCommand().decode(byteArrayOf(0x96.toByte())) } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/PatchInfoCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/PatchInfoCommandTest.kt new file mode 100644 index 000000000000..6b8d0b2e1d8d --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/PatchInfoCommandTest.kt @@ -0,0 +1,126 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +/** + * Verifies [PatchInfoCommand] decodes RPT1 (0x93) + RPT2 (0x94): serial from bytes 2..14 (ASCII), + * firmware from 2..5 (ASCII), model from 11..16 (decimal, byte 10 skipped). + */ +internal class PatchInfoCommandTest { + + private val rpt1: Byte = 0x93.toByte() + private val rpt2: Byte = 0x94.toByte() + + private fun rpt1Frame(serial: String, result: Int = 0): ByteArray = + byteArrayOf(rpt1, result.toByte()) + serial.map { it.code.toByte() }.toByteArray() + + /** RPT2: [0x94, result, firmware(2..5), filler(6..10), model(11..16)]. */ + private fun rpt2Frame(firmware: String, model: List, result: Int = 0): ByteArray = + byteArrayOf(rpt2, result.toByte()) + + firmware.map { it.code.toByte() }.toByteArray() + + byteArrayOf(0, 0, 0, 0, 0) + // bytes 6..10 (byte 10 skipped by decode) + model.map { it.toByte() }.toByteArray() + + @Test + fun `encode is a bare 0x33 request`() { + assertThat(PatchInfoCommand().encode().toList()).containsExactly(0x33.toByte()) + } + + @Test + fun `expected response opcodes are RPT1 and RPT2`() { + assertThat(PatchInfoCommand().expectedResponseOpcodes).containsExactly(rpt1, rpt2) + } + + @Test + fun `decode extracts serial firmware model and result codes`() { + val resp = PatchInfoCommand().decode( + mapOf( + rpt1 to rpt1Frame("EO12507099001"), + rpt2 to rpt2Frame("T166", listOf(1, 2, 3, 4, 5, 6)) + ) + ) + + assertThat(resp.serialNumber).isEqualTo("EO12507099001") + assertThat(resp.firmwareVersion).isEqualTo("T166") + assertThat(resp.modelName).isEqualTo("123456") // decimal join of bytes 11..16 + assertThat(resp.serialResultCode).isEqualTo(0) + assertThat(resp.detailResultCode).isEqualTo(0) + } + + @Test + fun `decode handles the real 16-byte RPT2 frame - model clamps to bytes 11 to 15`() { + // Real pump 0x94 frame is 16 bytes (byte 16 absent); model reads bytes 11..15 only. + val resp = PatchInfoCommand().decode( + mapOf( + rpt1 to rpt1Frame("EO12507099001"), + rpt2 to rpt2Frame("T166", listOf(1, 2, 3, 4, 5)) // 5 model bytes → 16-byte frame + ) + ) + assertThat(resp.firmwareVersion).isEqualTo("T166") + assertThat(resp.modelName).isEqualTo("12345") + } + + @Test + fun `decode reads model bytes as decimal values not ascii`() { + // Byte value 65 (ASCII 'A') must decode to "65" — model bytes are decimal, not ASCII. + val resp = PatchInfoCommand().decode( + mapOf( + rpt1 to rpt1Frame("EO12507099001"), + rpt2 to rpt2Frame("T166", listOf(65, 66, 0, 0, 0, 0)) + ) + ) + assertThat(resp.modelName).isEqualTo("65660000") + } + + @Test + fun `decode surfaces non-zero result codes`() { + val resp = PatchInfoCommand().decode( + mapOf( + rpt1 to rpt1Frame("EO12507099001", result = 1), + rpt2 to rpt2Frame("T166", listOf(1, 2, 3, 4, 5, 6), result = 1) + ) + ) + assertThat(resp.serialResultCode).isEqualTo(1) + assertThat(resp.detailResultCode).isEqualTo(1) + } + + @Test + fun `decode throws when RPT1 is missing`() { + assertFailsWith { + PatchInfoCommand().decode(mapOf(rpt2 to rpt2Frame("T166", listOf(1, 2, 3, 4, 5, 6)))) + } + } + + @Test + fun `decode throws when RPT2 is missing`() { + assertFailsWith { + PatchInfoCommand().decode(mapOf(rpt1 to rpt1Frame("EO12507099001"))) + } + } + + @Test + fun `decode throws when RPT1 is too short`() { + assertFailsWith { + PatchInfoCommand().decode( + mapOf( + rpt1 to byteArrayOf(rpt1, 0x00, 0x41), // only 3 bytes + rpt2 to rpt2Frame("T166", listOf(1, 2, 3, 4, 5, 6)) + ) + ) + } + } + + @Test + fun `decode throws when RPT2 is too short`() { + assertFailsWith { + PatchInfoCommand().decode( + mapOf( + rpt1 to rpt1Frame("EO12507099001"), + rpt2 to byteArrayOf(rpt2, 0x00, 0x54) // only 3 bytes + ) + ) + } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/PumpResumeCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/PumpResumeCommandTest.kt new file mode 100644 index 000000000000..72096462db8e --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/PumpResumeCommandTest.kt @@ -0,0 +1,71 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class PumpResumeCommandTest { + + @Test + fun `encode mode and subId 4`() { + assertThat(PumpResumeCommand(mode = 1, subId = 4).encode().toList()) + .containsExactly(0x27.toByte(), 1.toByte(), 0x04.toByte()).inOrder() + } + + @Test + fun `encode subId 1`() { + assertThat(PumpResumeCommand(mode = 4, subId = 1).encode().toList()) + .containsExactly(0x27.toByte(), 4.toByte(), 0x01.toByte()).inOrder() + } + + @Test + fun `encode subId 0`() { + assertThat(PumpResumeCommand(mode = 2, subId = 0).encode().toList()) + .containsExactly(0x27.toByte(), 2.toByte(), 0x00.toByte()).inOrder() + } + + @Test + fun `decode full response with subId`() { + val r = PumpResumeCommand(mode = 1, subId = 4).decode(byteArrayOf(0x87.toByte(), 0x00, 0x03, 0x04)) + assertThat(r.resultCode).isEqualTo(0) + assertThat(r.mode).isEqualTo(3) + assertThat(r.subId).isEqualTo(4) + } + + @Test + fun `decode short response defaults subId to 0`() { + val r = PumpResumeCommand(mode = 1, subId = 4).decode(byteArrayOf(0x87.toByte(), 0x00, 0x02)) + assertThat(r.resultCode).isEqualTo(0) + assertThat(r.mode).isEqualTo(2) + assertThat(r.subId).isEqualTo(0) + } + + @Test + fun `mode below range throws`() { + assertFailsWith { PumpResumeCommand(mode = 0, subId = 4) } + } + + @Test + fun `mode above range throws`() { + assertFailsWith { PumpResumeCommand(mode = 5, subId = 4) } + } + + @Test + fun `invalid subId throws`() { + assertFailsWith { PumpResumeCommand(mode = 1, subId = 2) } + } + + @Test + fun `decode wrong opcode throws`() { + assertFailsWith { + PumpResumeCommand(mode = 1, subId = 4).decode(byteArrayOf(0x88.toByte(), 0x00, 0x01)) + } + } + + @Test + fun `decode too short throws`() { + assertFailsWith { + PumpResumeCommand(mode = 1, subId = 4).decode(byteArrayOf(0x87.toByte(), 0x00)) + } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/PumpStopCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/PumpStopCommandTest.kt new file mode 100644 index 000000000000..386bbfa624a4 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/PumpStopCommandTest.kt @@ -0,0 +1,69 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class PumpStopCommandTest { + + @Test + fun `encode splits duration into hours and minutes`() { + // 125 min = 2 h 5 min + assertThat(PumpStopCommand(durationMinutes = 125, subId = 1).encode().toList()) + .containsExactly(0x26.toByte(), 2.toByte(), 5.toByte(), 1.toByte()).inOrder() + } + + @Test + fun `encode zero duration subId zero`() { + assertThat(PumpStopCommand(durationMinutes = 0, subId = 0).encode().toList()) + .containsExactly(0x26.toByte(), 0.toByte(), 0.toByte(), 0.toByte()).inOrder() + } + + @Test + fun `encode max duration 359 minutes is 5h 59m`() { + assertThat(PumpStopCommand(durationMinutes = 359, subId = 1).encode().toList()) + .containsExactly(0x26.toByte(), 5.toByte(), 59.toByte(), 1.toByte()).inOrder() + } + + @Test + fun `decode returns result code`() { + assertThat(PumpStopCommand(durationMinutes = 30, subId = 0).decode(byteArrayOf(0x86.toByte(), 0x00)).resultCode) + .isEqualTo(0) + } + + @Test + fun `decode returns non-zero result code`() { + assertThat(PumpStopCommand(durationMinutes = 30, subId = 0).decode(byteArrayOf(0x86.toByte(), 0x05)).resultCode) + .isEqualTo(5) + } + + @Test + fun `duration above range throws`() { + // 360 min = 6 h → hours out of 0..5 + assertFailsWith { PumpStopCommand(durationMinutes = 360, subId = 0) } + } + + @Test + fun `negative duration throws`() { + assertFailsWith { PumpStopCommand(durationMinutes = -1, subId = 0) } + } + + @Test + fun `subId out of range throws`() { + assertFailsWith { PumpStopCommand(durationMinutes = 30, subId = 2) } + } + + @Test + fun `decode wrong opcode throws`() { + assertFailsWith { + PumpStopCommand(durationMinutes = 30, subId = 0).decode(byteArrayOf(0x87.toByte(), 0x00)) + } + } + + @Test + fun `decode too short throws`() { + assertFailsWith { + PumpStopCommand(durationMinutes = 30, subId = 0).decode(byteArrayOf(0x86.toByte())) + } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/SafetyCheckCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/SafetyCheckCommandTest.kt new file mode 100644 index 000000000000..9e5615ec95a1 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/SafetyCheckCommandTest.kt @@ -0,0 +1,59 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class SafetyCheckCommandTest { + + @Test + fun `encode is a bare 0x12 request`() { + assertThat(SafetyCheckCommand().encode().toList()).containsExactly(0x12.toByte()) + } + + @Test + fun `decode progress frame uses fallback duration and is not terminal`() { + val cmd = SafetyCheckCommand() + val r = cmd.decode(byteArrayOf(0x72, SafetyCheckCommand.REP_REQUEST.toByte(), 2, 50)) // size 4 → fallback 210 + assertThat(r.resultCode).isEqualTo(SafetyCheckCommand.REP_REQUEST) + assertThat(r.insulinVolume).isEqualTo(250) // 2*100 + 50 + assertThat(r.durationSeconds).isEqualTo(210) + assertThat(cmd.isTerminal(r)).isFalse() + } + + @Test + fun `decode full success frame reads duration and is terminal`() { + val cmd = SafetyCheckCommand() + val r = cmd.decode(byteArrayOf(0x72, 0x00, 2, 50, 3, 30)) // SUCCESS, duration 3*60+30 + assertThat(r.resultCode).isEqualTo(SafetyCheckCommand.RESULT_SUCCESS) + assertThat(r.insulinVolume).isEqualTo(250) + assertThat(r.durationSeconds).isEqualTo(210) + assertThat(cmd.isTerminal(r)).isTrue() + } + + @Test + fun `error result codes are terminal`() { + val cmd = SafetyCheckCommand() + val expired = cmd.decode(byteArrayOf(0x72, 2, 0, 0)) // EXPIRED + assertThat(cmd.isTerminal(expired)).isTrue() + } + + @Test + fun `both progress codes are non-terminal`() { + val cmd = SafetyCheckCommand() + val p1 = cmd.decode(byteArrayOf(0x72, SafetyCheckCommand.REP_REQUEST.toByte(), 0, 0)) + val p2 = cmd.decode(byteArrayOf(0x72, SafetyCheckCommand.REP_REQUEST1.toByte(), 0, 0)) + assertThat(cmd.isTerminal(p1)).isFalse() + assertThat(cmd.isTerminal(p2)).isFalse() + } + + @Test + fun `decode too short throws`() { + assertFailsWith { SafetyCheckCommand().decode(byteArrayOf(0x72, 0x00, 0x00)) } + } + + @Test + fun `decode wrong opcode throws`() { + assertFailsWith { SafetyCheckCommand().decode(byteArrayOf(0x73, 0x00, 0x00, 0x00)) } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/SetTimeCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/SetTimeCommandTest.kt new file mode 100644 index 000000000000..d6d1e70baf41 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/SetTimeCommandTest.kt @@ -0,0 +1,66 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class SetTimeCommandTest { + + private val fixed = DateTime(2026, 7, 15, 8, 30, 45) // yy=26, MM=7, dd=15, HH=8, mm=30, ss=45 + + @Test + fun `encode packs opcode subId datetime volume aidMode`() { + val bytes = SetTimeCommand(subId = 1, volume = 200, aidMode = 0, dateTime = fixed).encode().toList() + assertThat(bytes).containsExactly( + 0x11.toByte(), 1.toByte(), + 26.toByte(), 7.toByte(), 15.toByte(), 8.toByte(), 30.toByte(), 45.toByte(), + 2.toByte(), 0.toByte(), // volume 200 -> [2, 0] + 0.toByte() + ).inOrder() + } + + @Test + fun `volume splits into hundreds and remainder`() { + val bytes = SetTimeCommand(subId = 0, volume = 250, aidMode = 0, dateTime = fixed).encode() + assertThat(bytes[8]).isEqualTo(2.toByte()) // 250 / 100 + assertThat(bytes[9]).isEqualTo(50.toByte()) // 250 % 100 + } + + @Test + fun `volume out of range throws`() { + assertFailsWith { SetTimeCommand(0, 400, 0, fixed).encode() } + } + + @Test + fun `normal shape decodes 0x71 result`() { + assertThat(SetTimeCommand(1, 200, 0, fixed).decode(byteArrayOf(0x71, 0x00)).resultCode).isEqualTo(0) + } + + @Test + fun `normal shape rejects wrong opcode`() { + assertFailsWith { SetTimeCommand(1, 200, 0, fixed).decode(byteArrayOf(0x72, 0x00)) } + } + + @Test + fun `activation shape expects both info report opcodes and shares the request encoding`() { + val cmd = SetTimeForPatchInfoCommand(subId = 0, volume = 200, aidMode = 0, dateTime = fixed) + assertThat(cmd.expectedResponseOpcodes).containsExactly(0x93.toByte(), 0x94.toByte()) + assertThat(cmd.encode().toList()) + .isEqualTo(SetTimeCommand(0, 200, 0, fixed).encode().toList()) // identical 0x11 request + } + + @Test + fun `activation shape decodes the two info frames into a PatchInfoResponse`() { + val rpt1 = byteArrayOf(0x93.toByte(), 0x00) + "EO12507099001".map { it.code.toByte() }.toByteArray() + val rpt2 = byteArrayOf(0x94.toByte(), 0x00) + + "T166".map { it.code.toByte() }.toByteArray() + + byteArrayOf(0, 0, 0, 0, 0) + + byteArrayOf(1, 2, 3, 4, 5) + val resp = SetTimeForPatchInfoCommand(0, 200, 0, fixed).decode( + mapOf(0x93.toByte() to rpt1, 0x94.toByte() to rpt2) + ) + assertThat(resp.serialNumber).isEqualTo("EO12507099001") + assertThat(resp.firmwareVersion).isEqualTo("T166") + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/TempBasalCancelCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/TempBasalCancelCommandTest.kt new file mode 100644 index 000000000000..682da7457e8b --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/TempBasalCancelCommandTest.kt @@ -0,0 +1,34 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class TempBasalCancelCommandTest { + + @Test + fun `encode is single opcode byte 0x2D`() { + assertThat(TempBasalCancelCommand().encode().toList()) + .containsExactly(0x2D.toByte()).inOrder() + } + + @Test + fun `decode returns result code`() { + assertThat(TempBasalCancelCommand().decode(byteArrayOf(0x8D.toByte(), 0x00)).resultCode).isEqualTo(0) + } + + @Test + fun `decode returns non-zero result code`() { + assertThat(TempBasalCancelCommand().decode(byteArrayOf(0x8D.toByte(), 0x05)).resultCode).isEqualTo(5) + } + + @Test + fun `decode wrong opcode throws`() { + assertFailsWith { TempBasalCancelCommand().decode(byteArrayOf(0x8C.toByte(), 0x00)) } + } + + @Test + fun `decode too short throws`() { + assertFailsWith { TempBasalCancelCommand().decode(byteArrayOf(0x8D.toByte())) } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/TempBasalCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/TempBasalCommandTest.kt new file mode 100644 index 000000000000..a3fe7660460a --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/TempBasalCommandTest.kt @@ -0,0 +1,83 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class TempBasalCommandTest { + + @Test + fun `byUnit encodes 6-byte frame with trailing 0x00`() { + assertThat(TempBasalCommand.byUnit(infusionUnit = 1.5, infusionHour = 2, infusionMin = 30).encode().toList()) + .containsExactly(0x23.toByte(), 1.toByte(), 50.toByte(), 2.toByte(), 30.toByte(), 0x00.toByte()).inOrder() + } + + @Test + fun `byUnit rounds fractional unit to centi`() { + assertThat(TempBasalCommand.byUnit(infusionUnit = 0.05, infusionHour = 0, infusionMin = 0).encode().toList()) + .containsExactly(0x23.toByte(), 0.toByte(), 5.toByte(), 0.toByte(), 0.toByte(), 0x00.toByte()).inOrder() + } + + @Test + fun `byPercent encodes 5-byte frame with no trailing byte and divides by 100`() { + // 150 % → 1.50 → [1, 50] + assertThat(TempBasalCommand.byPercent(infusionPercent = 150, infusionHour = 1, infusionMin = 0).encode().toList()) + .containsExactly(0x23.toByte(), 1.toByte(), 50.toByte(), 1.toByte(), 0.toByte()).inOrder() + } + + @Test + fun `byPercent below 100 encodes fractional whole-part zero`() { + // 90 % → 0.90 → [0, 90] + assertThat(TempBasalCommand.byPercent(infusionPercent = 90, infusionHour = 5, infusionMin = 45).encode().toList()) + .containsExactly(0x23.toByte(), 0.toByte(), 90.toByte(), 5.toByte(), 45.toByte()).inOrder() + } + + @Test + fun `byUnit accepts any unit value - no range check`() { + // BY_UNIT has no value-range check on the wire, so this must NOT throw. + assertThat(TempBasalCommand.byUnit(infusionUnit = 99.0, infusionHour = 0, infusionMin = 0).encode().toList()) + .containsExactly(0x23.toByte(), 99.toByte(), 0.toByte(), 0.toByte(), 0.toByte(), 0x00.toByte()).inOrder() + } + + @Test + fun `decode returns result code`() { + val r = TempBasalCommand.byUnit(infusionUnit = 1.0, infusionHour = 0, infusionMin = 0).decode(byteArrayOf(0x83.toByte(), 0x00)) + assertThat(r.resultCode).isEqualTo(0) + } + + @Test + fun `byUnit hour above range throws`() { + assertFailsWith { TempBasalCommand.byUnit(infusionUnit = 1.0, infusionHour = 25, infusionMin = 0) } + } + + @Test + fun `byUnit minute above range throws`() { + assertFailsWith { TempBasalCommand.byUnit(infusionUnit = 1.0, infusionHour = 0, infusionMin = 60) } + } + + @Test + fun `byPercent value above range throws`() { + // 20001 / 100.0 = 200.01 > 200.0 + assertFailsWith { TempBasalCommand.byPercent(infusionPercent = 20001, infusionHour = 0, infusionMin = 0) } + } + + @Test + fun `byPercent negative value below range throws`() { + // -1 / 100.0 = -0.01 < 0.0 + assertFailsWith { TempBasalCommand.byPercent(infusionPercent = -1, infusionHour = 0, infusionMin = 0) } + } + + @Test + fun `decode wrong opcode throws`() { + assertFailsWith { + TempBasalCommand.byUnit(infusionUnit = 1.0, infusionHour = 0, infusionMin = 0).decode(byteArrayOf(0x84.toByte(), 0x00)) + } + } + + @Test + fun `decode too short throws`() { + assertFailsWith { + TempBasalCommand.byUnit(infusionUnit = 1.0, infusionHour = 0, infusionMin = 0).decode(byteArrayOf(0x83.toByte())) + } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/ThresholdSetupCommandTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/ThresholdSetupCommandTest.kt new file mode 100644 index 000000000000..b78c3430ec68 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/commands/ThresholdSetupCommandTest.kt @@ -0,0 +1,53 @@ +package app.aaps.pump.carelevo.ble.commands + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class ThresholdSetupCommandTest { + + @Test + fun `encode packs the full threshold bundle`() { + val bytes = ThresholdSetupCommand( + insulinRemainsThreshold = 100, expiryThreshold = 116, + maxBasalSpeed = 2.5, maxBolusDose = 15.0, buzzUse = true + ).encode().toList() + assertThat(bytes).containsExactly( + 0x1B.toByte(), 100.toByte(), 116.toByte(), 2.toByte(), 50.toByte(), 15.toByte(), 0.toByte(), 0x01.toByte() + ).inOrder() + } + + @Test + fun `buzzUse=false encodes 0x00 (double inversion)`() { + val bytes = ThresholdSetupCommand(100, 116, 2.5, 15.0, buzzUse = false).encode().toList() + assertThat(bytes.last()).isEqualTo(0x00.toByte()) + } + + @Test + fun `insulinRemainsThreshold over 255 is rejected`() { + // The threshold is a single wire byte, so 300 would wrap to 0x2C = 44 — a silently + // misprogrammed low-insulin alarm. Reject it loudly instead. + assertFailsWith { ThresholdSetupCommand(300, 116, 2.5, 15.0, buzzUse = true) } + // 255 is the highest value that still fits the single wire byte. + val bytes = ThresholdSetupCommand(255, 116, 2.5, 15.0, buzzUse = true).encode() + assertThat(bytes[1]).isEqualTo(255.toByte()) + } + + @Test + fun `decode returns result code`() { + assertThat(ThresholdSetupCommand(100, 116, 2.5, 15.0, true).decode(byteArrayOf(0x7B, 0x00)).resultCode).isEqualTo(0) + } + + @Test + fun `out-of-range args throw`() { + assertFailsWith { ThresholdSetupCommand(5, 116, 2.5, 15.0, true) } // insRemain < 10 + assertFailsWith { ThresholdSetupCommand(100, 200, 2.5, 15.0, true) } // expiry > 167 + assertFailsWith { ThresholdSetupCommand(100, 116, 20.0, 15.0, true) } // basal speed > 15 + assertFailsWith { ThresholdSetupCommand(100, 116, 2.5, 30.0, true) } // bolus dose > 25 + } + + @Test + fun `decode wrong opcode throws`() { + assertFailsWith { ThresholdSetupCommand(100, 116, 2.5, 15.0, true).decode(byteArrayOf(0x7C, 0x00)) } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/data/CarelevoBleDataTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/data/CarelevoBleDataTest.kt new file mode 100644 index 000000000000..332ea89d8c52 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/data/CarelevoBleDataTest.kt @@ -0,0 +1,489 @@ +package app.aaps.pump.carelevo.ble.data + +import android.bluetooth.BluetoothDevice +import app.aaps.pump.carelevo.ble.data.BondingState.Companion.codeToBondingResult +import app.aaps.pump.carelevo.ble.data.DeviceModuleState.Companion.codeToDeviceResult +import app.aaps.pump.carelevo.ble.data.NotificationState.Companion.codeToNotificationResult +import app.aaps.pump.carelevo.ble.data.PeripheralConnectionState.Companion.codeToConnectionResult +import app.aaps.pump.carelevo.ble.data.ServiceDiscoverState.Companion.codeToDiscoverResult +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import java.util.UUID +import kotlin.test.assertFailsWith + +/** + * Pure-logic coverage for the Carelevo BLE data layer: + * - CarelevoBleEnums.kt (enum code lookups) + * - CarelevoBleModels.kt (BleState + its many predicate extensions) + * - CarelevoBleParams.kt (BleParams / ConfigParams) + * - CarelevoBleResults.kt (CharacterResult custom equals/hashCode, sealed result hierarchies) + */ +internal class CarelevoBleDataTest { + + // --------------------------------------------------------------------------------------------- + // BondingState.codeToBondingResult + // --------------------------------------------------------------------------------------------- + + @Test + fun `bonding code lookups map to the right state`() { + assertThat((-1).codeToBondingResult()).isEqualTo(BondingState.BOND_NONE) + assertThat(10.codeToBondingResult()).isEqualTo(BondingState.BOND_NONE) + assertThat(11.codeToBondingResult()).isEqualTo(BondingState.BOND_BONDING) + assertThat(12.codeToBondingResult()).isEqualTo(BondingState.BOND_BONDED) + } + + @Test + fun `invalid bonding code throws`() { + assertFailsWith { 99.codeToBondingResult() } + assertFailsWith { 0.codeToBondingResult() } + } + + @Test + fun `BondingState enumerates all four values`() { + assertThat(BondingState.entries).containsExactly( + BondingState.BOND_NONE, BondingState.BOND_BONDING, BondingState.BOND_BONDED, BondingState.BOND_ERROR + ).inOrder() + assertThat(BondingState.valueOf("BOND_ERROR")).isEqualTo(BondingState.BOND_ERROR) + } + + // --------------------------------------------------------------------------------------------- + // PeripheralConnectionState.codeToConnectionResult + // --------------------------------------------------------------------------------------------- + + @Test + fun `connection code lookups map to the right state`() { + assertThat((-1).codeToConnectionResult()).isEqualTo(PeripheralConnectionState.CONN_STATE_NONE) + assertThat(0.codeToConnectionResult()).isEqualTo(PeripheralConnectionState.CONN_STATE_DISCONNECTED) + assertThat(1.codeToConnectionResult()).isEqualTo(PeripheralConnectionState.CONN_STATE_CONNECTING) + assertThat(2.codeToConnectionResult()).isEqualTo(PeripheralConnectionState.CONN_STATE_CONNECTED) + assertThat(3.codeToConnectionResult()).isEqualTo(PeripheralConnectionState.CONN_STATE_DISCONNECTING) + } + + @Test + fun `invalid connection code throws`() { + assertFailsWith { 4.codeToConnectionResult() } + assertFailsWith { (-2).codeToConnectionResult() } + } + + @Test + fun `PeripheralConnectionState enumerates all five values`() { + assertThat(PeripheralConnectionState.entries).hasSize(5) + } + + // --------------------------------------------------------------------------------------------- + // ServiceDiscoverState.codeToDiscoverResult + // --------------------------------------------------------------------------------------------- + + @Test + fun `discover code lookups map to the right state`() { + assertThat((-1).codeToDiscoverResult()).isEqualTo(ServiceDiscoverState.DISCOVER_STATE_CLEARED) + assertThat(0.codeToDiscoverResult()).isEqualTo(ServiceDiscoverState.DISCOVER_STATE_DISCOVERED) + } + + @Test + fun `any other discover code falls through to FAILED`() { + // The when has no throwing else; every code other than -1 / 0 is treated as a failed discovery. + assertThat(1.codeToDiscoverResult()).isEqualTo(ServiceDiscoverState.DISCOVER_STATE_FAILED) + assertThat(2.codeToDiscoverResult()).isEqualTo(ServiceDiscoverState.DISCOVER_STATE_FAILED) + assertThat(133.codeToDiscoverResult()).isEqualTo(ServiceDiscoverState.DISCOVER_STATE_FAILED) + assertThat((-99).codeToDiscoverResult()).isEqualTo(ServiceDiscoverState.DISCOVER_STATE_FAILED) + } + + @Test + fun `ServiceDiscoverState enumerates all four values`() { + assertThat(ServiceDiscoverState.entries).hasSize(4) + } + + // --------------------------------------------------------------------------------------------- + // DeviceModuleState.codeToDeviceResult + // --------------------------------------------------------------------------------------------- + + @Test + fun `device code lookups map to the right state`() { + assertThat((-1).codeToDeviceResult()).isEqualTo(DeviceModuleState.DEVICE_NONE) + assertThat(10.codeToDeviceResult()).isEqualTo(DeviceModuleState.DEVICE_STATE_OFF) + assertThat(11.codeToDeviceResult()).isEqualTo(DeviceModuleState.DEVICE_STATE_TURNING_ON) + assertThat(12.codeToDeviceResult()).isEqualTo(DeviceModuleState.DEVICE_STATE_ON) + assertThat(13.codeToDeviceResult()).isEqualTo(DeviceModuleState.DEVICE_STATE_TUNING_OFF) + } + + @Test + fun `invalid device code throws`() { + assertFailsWith { 14.codeToDeviceResult() } + assertFailsWith { 0.codeToDeviceResult() } + } + + @Test + fun `DeviceModuleState enumerates all five values`() { + assertThat(DeviceModuleState.entries).hasSize(5) + } + + // --------------------------------------------------------------------------------------------- + // NotificationState.codeToNotificationResult + // --------------------------------------------------------------------------------------------- + + @Test + fun `notification code lookups map to the right state`() { + assertThat((-1).codeToNotificationResult()).isEqualTo(NotificationState.NOTIFICATION_NONE) + assertThat(0.codeToNotificationResult()).isEqualTo(NotificationState.NOTIFICATION_DISABLED) + assertThat(1.codeToNotificationResult()).isEqualTo(NotificationState.NOTIFICATION_ENABLED) + } + + @Test + fun `invalid notification code throws`() { + assertFailsWith { 2.codeToNotificationResult() } + assertFailsWith { (-5).codeToNotificationResult() } + } + + @Test + fun `NotificationState enumerates all three values`() { + assertThat(NotificationState.entries).hasSize(3) + } + + // --------------------------------------------------------------------------------------------- + // FailureState + // --------------------------------------------------------------------------------------------- + + @Test + fun `FailureState enumerates every failure cause`() { + assertThat(FailureState.entries).containsExactly( + FailureState.FAILURE_INVALID_PARAMS, + FailureState.FAILURE_RESOURCE_NOT_INITIALIZED, + FailureState.FAILURE_PERMISSION_NOT_GRANTED, + FailureState.FAILURE_BT_NOT_ENABLED, + FailureState.FAILURE_COMMAND_NOT_EXECUTABLE + ).inOrder() + } + + // --------------------------------------------------------------------------------------------- + // BleParams / ConfigParams + // --------------------------------------------------------------------------------------------- + + @Test + fun `BleParams carries its four uuids and supports value semantics`() { + val cccd = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + val service = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb") + val tx = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb") + val rx = UUID.fromString("0000fff2-0000-1000-8000-00805f9b34fb") + + val params = BleParams(cccd, service, tx, rx) + assertThat(params.cccd).isEqualTo(cccd) + assertThat(params.serviceUuid).isEqualTo(service) + assertThat(params.txUuid).isEqualTo(tx) + assertThat(params.rxUUID).isEqualTo(rx) + + assertThat(params).isEqualTo(BleParams(cccd, service, tx, rx)) + assertThat(params.copy(txUuid = rx)).isNotEqualTo(params) + } + + @Test + fun `ConfigParams defaults to foreground and can be overridden`() { + assertThat(ConfigParams().isForeground).isTrue() + assertThat(ConfigParams(isForeground = false).isForeground).isFalse() + assertThat(ConfigParams()).isEqualTo(ConfigParams(true)) + assertThat(ConfigParams().copy(isForeground = false)).isEqualTo(ConfigParams(false)) + } + + // --------------------------------------------------------------------------------------------- + // CharacterResult (custom equals / hashCode) + // --------------------------------------------------------------------------------------------- + + private val charUuid: UUID = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb") + + @Test + fun `CharacterResult equals is reflexive`() { + val r = CharacterResult(charUuid, byteArrayOf(1, 2, 3), 0) + // exercises the `this === other` short-circuit branch + assertThat(r.equals(r)).isTrue() + } + + @Test + fun `CharacterResult is not equal to null or a different type`() { + val r = CharacterResult(charUuid, byteArrayOf(1), 0) + assertThat(r.equals(null)).isFalse() + assertThat(r.equals("not a result")).isFalse() + } + + @Test + fun `CharacterResult equal when uuid, value bytes and status match`() { + val a = CharacterResult(charUuid, byteArrayOf(1, 2, 3), 7) + val b = CharacterResult(charUuid, byteArrayOf(1, 2, 3), 7) + assertThat(a).isEqualTo(b) + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `CharacterResult differs when uuid differs`() { + val a = CharacterResult(charUuid, byteArrayOf(1), 0) + val b = CharacterResult(UUID.fromString("0000fff2-0000-1000-8000-00805f9b34fb"), byteArrayOf(1), 0) + assertThat(a).isNotEqualTo(b) + } + + @Test + fun `CharacterResult differs when value bytes differ`() { + val a = CharacterResult(charUuid, byteArrayOf(1, 2, 3), 0) + val b = CharacterResult(charUuid, byteArrayOf(9, 9, 9), 0) + assertThat(a).isNotEqualTo(b) + } + + @Test + fun `CharacterResult with non-null value differs from one with null value`() { + val withValue = CharacterResult(charUuid, byteArrayOf(1), 0) + val nullValue = CharacterResult(charUuid, null, 0) + assertThat(withValue).isNotEqualTo(nullValue) + // symmetric: null value side vs non-null side hits the `else if (other.value != null)` branch + assertThat(nullValue).isNotEqualTo(withValue) + } + + @Test + fun `CharacterResult with both values null compares by uuid and status`() { + val a = CharacterResult(charUuid, null, 5) + val b = CharacterResult(charUuid, null, 5) + assertThat(a).isEqualTo(b) + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + + assertThat(a).isNotEqualTo(CharacterResult(charUuid, null, 6)) + } + + @Test + fun `CharacterResult differs when status differs`() { + val a = CharacterResult(charUuid, byteArrayOf(1), 0) + val b = CharacterResult(charUuid, byteArrayOf(1), 1) + assertThat(a).isNotEqualTo(b) + } + + @Test + fun `CharacterResult all-null defaults hash to zero`() { + val empty = CharacterResult() + assertThat(empty.uuidCharacteristic).isNull() + assertThat(empty.value).isNull() + assertThat(empty.codeStatus).isNull() + assertThat(empty.hashCode()).isEqualTo(0) + assertThat(empty).isEqualTo(CharacterResult()) + } + + // --------------------------------------------------------------------------------------------- + // PeripheralScanResult (sealed) + // --------------------------------------------------------------------------------------------- + + @Test + fun `PeripheralScanResult subtypes expose their device list through the base value`() { + val devices = listOf(ScannedDevice(mock(), rssi = -55)) + val init: PeripheralScanResult = PeripheralScanResult.Init(devices) + val success: PeripheralScanResult = PeripheralScanResult.Success(devices) + val failed: PeripheralScanResult = PeripheralScanResult.Failed(emptyList()) + + assertThat(init.value).isEqualTo(devices) + assertThat(success.value).isEqualTo(devices) + assertThat(failed.value).isEmpty() + + assertThat(init).isInstanceOf(PeripheralScanResult.Init::class.java) + assertThat(success).isInstanceOf(PeripheralScanResult.Success::class.java) + assertThat(failed).isInstanceOf(PeripheralScanResult.Failed::class.java) + } + + @Test + fun `PeripheralScanResult data classes compare by value`() { + assertThat(PeripheralScanResult.Success(emptyList())).isEqualTo(PeripheralScanResult.Success(emptyList())) + assertThat(PeripheralScanResult.Init(emptyList())).isNotEqualTo(PeripheralScanResult.Success(emptyList())) + } + + // --------------------------------------------------------------------------------------------- + // CommandResult (sealed) + // --------------------------------------------------------------------------------------------- + + @Test + fun `CommandResult Pending and Success carry their payload`() { + val pending: CommandResult = CommandResult.Pending("waiting") + val success: CommandResult = CommandResult.Success("done") + assertThat((pending as CommandResult.Pending).data).isEqualTo("waiting") + assertThat((success as CommandResult.Success).data).isEqualTo("done") + assertThat(pending).isEqualTo(CommandResult.Pending("waiting")) + } + + @Test + fun `CommandResult Failure carries state and message`() { + val failure = CommandResult.Failure(FailureState.FAILURE_BT_NOT_ENABLED, "bluetooth off") + assertThat(failure.state).isEqualTo(FailureState.FAILURE_BT_NOT_ENABLED) + assertThat(failure.message).isEqualTo("bluetooth off") + assertThat(failure).isEqualTo(CommandResult.Failure(FailureState.FAILURE_BT_NOT_ENABLED, "bluetooth off")) + } + + @Test + fun `CommandResult Error wraps the throwable`() { + val boom = IllegalStateException("boom") + val error = CommandResult.Error(boom) + assertThat(error.e).isSameInstanceAs(boom) + } + + // --------------------------------------------------------------------------------------------- + // ScannedDevice + // --------------------------------------------------------------------------------------------- + + @Test + fun `ScannedDevice holds the device and rssi and is mutable`() { + val device = mock() + val scanned = ScannedDevice(device, rssi = -70) + assertThat(scanned.device).isSameInstanceAs(device) + assertThat(scanned.rssi).isEqualTo(-70) + + scanned.rssi = -40 + assertThat(scanned.rssi).isEqualTo(-40) + assertThat(scanned.copy(rssi = -10).rssi).isEqualTo(-10) + } + + // --------------------------------------------------------------------------------------------- + // BleState predicate extensions + // --------------------------------------------------------------------------------------------- + + private fun state( + enabled: DeviceModuleState = DeviceModuleState.DEVICE_STATE_ON, + bonded: BondingState = BondingState.BOND_BONDED, + discovered: ServiceDiscoverState = ServiceDiscoverState.DISCOVER_STATE_DISCOVERED, + connected: PeripheralConnectionState = PeripheralConnectionState.CONN_STATE_CONNECTED, + notification: NotificationState = NotificationState.NOTIFICATION_ENABLED + ) = BleState( + isEnabled = enabled, + isBonded = bonded, + isServiceDiscovered = discovered, + isConnected = connected, + isNotificationEnabled = notification + ) + + /** The canonical fully-connected state (all extensions that require "fully up" are true here). */ + private val connectedState = state() + + @Test + fun `isAvailable is true unless the module is off`() { + assertThat(state(enabled = DeviceModuleState.DEVICE_STATE_ON).isAvailable()).isTrue() + assertThat(state(enabled = DeviceModuleState.DEVICE_NONE).isAvailable()).isTrue() + assertThat(state(enabled = DeviceModuleState.DEVICE_STATE_TURNING_ON).isAvailable()).isTrue() + assertThat(state(enabled = DeviceModuleState.DEVICE_STATE_OFF).isAvailable()).isFalse() + } + + @Test + fun `isPeripheralConnected tracks only the connection field`() { + assertThat(state(connected = PeripheralConnectionState.CONN_STATE_CONNECTED).isPeripheralConnected()).isTrue() + assertThat(state(connected = PeripheralConnectionState.CONN_STATE_DISCONNECTED).isPeripheralConnected()).isFalse() + } + + @Test + fun `isConnected requires the full happy path`() { + assertThat(connectedState.isConnected()).isTrue() + // flip each of the five fields once + assertThat(connectedState.copy(isEnabled = DeviceModuleState.DEVICE_STATE_OFF).isConnected()).isFalse() + assertThat(connectedState.copy(isBonded = BondingState.BOND_NONE).isConnected()).isFalse() + assertThat(connectedState.copy(isConnected = PeripheralConnectionState.CONN_STATE_DISCONNECTED).isConnected()).isFalse() + assertThat(connectedState.copy(isServiceDiscovered = ServiceDiscoverState.DISCOVER_STATE_NONE).isConnected()).isFalse() + assertThat(connectedState.copy(isNotificationEnabled = NotificationState.NOTIFICATION_DISABLED).isConnected()).isFalse() + } + + @Test + fun `shouldBeNotificationEnabled matches the fully-connected state`() { + assertThat(connectedState.shouldBeNotificationEnabled()).isTrue() + assertThat(connectedState.copy(isNotificationEnabled = NotificationState.NOTIFICATION_DISABLED).shouldBeNotificationEnabled()).isFalse() + } + + @Test + fun `shouldBeConnected requires connection but not yet discovery or notifications`() { + val s = state( + discovered = ServiceDiscoverState.DISCOVER_STATE_NONE, + connected = PeripheralConnectionState.CONN_STATE_CONNECTED, + notification = NotificationState.NOTIFICATION_DISABLED + ) + assertThat(s.shouldBeConnected()).isTrue() + // already discovered => no longer "should be connected" + assertThat(s.copy(isServiceDiscovered = ServiceDiscoverState.DISCOVER_STATE_DISCOVERED).shouldBeConnected()).isFalse() + // notifications already enabled => false + assertThat(s.copy(isNotificationEnabled = NotificationState.NOTIFICATION_ENABLED).shouldBeConnected()).isFalse() + // not connected => false + assertThat(s.copy(isConnected = PeripheralConnectionState.CONN_STATE_DISCONNECTED).shouldBeConnected()).isFalse() + } + + @Test + fun `shouldBeDiscovered requires discovery done but notifications not yet enabled`() { + val s = state( + discovered = ServiceDiscoverState.DISCOVER_STATE_DISCOVERED, + notification = NotificationState.NOTIFICATION_DISABLED + ) + assertThat(s.shouldBeDiscovered()).isTrue() + assertThat(s.copy(isNotificationEnabled = NotificationState.NOTIFICATION_ENABLED).shouldBeDiscovered()).isFalse() + assertThat(s.copy(isServiceDiscovered = ServiceDiscoverState.DISCOVER_STATE_NONE).shouldBeDiscovered()).isFalse() + assertThat(s.copy(isBonded = BondingState.BOND_NONE).shouldBeDiscovered()).isFalse() + } + + @Test + fun `isDiscoverCleared matches the cleared teardown state`() { + val s = state( + bonded = BondingState.BOND_BONDED, + connected = PeripheralConnectionState.CONN_STATE_DISCONNECTED, + discovered = ServiceDiscoverState.DISCOVER_STATE_CLEARED, + notification = NotificationState.NOTIFICATION_DISABLED + ) + assertThat(s.isDiscoverCleared()).isTrue() + assertThat(s.copy(isConnected = PeripheralConnectionState.CONN_STATE_CONNECTED).isDiscoverCleared()).isFalse() + assertThat(s.copy(isServiceDiscovered = ServiceDiscoverState.DISCOVER_STATE_DISCOVERED).isDiscoverCleared()).isFalse() + } + + @Test + fun `isReInitialized and isAbnormalFailed share the re-init signature`() { + val s = state( + bonded = BondingState.BOND_NONE, + connected = PeripheralConnectionState.CONN_STATE_DISCONNECTED, + discovered = ServiceDiscoverState.DISCOVER_STATE_DISCOVERED, + notification = NotificationState.NOTIFICATION_DISABLED + ) + assertThat(s.isReInitialized()).isTrue() + assertThat(s.isAbnormalFailed()).isTrue() + // bonded again => neither holds + assertThat(s.copy(isBonded = BondingState.BOND_BONDED).isReInitialized()).isFalse() + assertThat(s.copy(isBonded = BondingState.BOND_BONDED).isAbnormalFailed()).isFalse() + } + + @Test + fun `isAbnormalBondingFailed requires an unbonded but still-connected link`() { + val s = state( + bonded = BondingState.BOND_NONE, + connected = PeripheralConnectionState.CONN_STATE_CONNECTED, + discovered = ServiceDiscoverState.DISCOVER_STATE_DISCOVERED, + notification = NotificationState.NOTIFICATION_DISABLED + ) + assertThat(s.isAbnormalBondingFailed()).isTrue() + assertThat(s.copy(isConnected = PeripheralConnectionState.CONN_STATE_DISCONNECTED).isAbnormalBondingFailed()).isFalse() + assertThat(s.copy(isBonded = BondingState.BOND_BONDED).isAbnormalBondingFailed()).isFalse() + } + + @Test + fun `isFailed requires unbonded, connected, no discovery`() { + val s = state( + bonded = BondingState.BOND_NONE, + connected = PeripheralConnectionState.CONN_STATE_CONNECTED, + discovered = ServiceDiscoverState.DISCOVER_STATE_NONE, + notification = NotificationState.NOTIFICATION_DISABLED + ) + assertThat(s.isFailed()).isTrue() + assertThat(s.copy(isServiceDiscovered = ServiceDiscoverState.DISCOVER_STATE_DISCOVERED).isFailed()).isFalse() + assertThat(s.copy(isNotificationEnabled = NotificationState.NOTIFICATION_ENABLED).isFailed()).isFalse() + } + + @Test + fun `isPairingFailed requires a fully torn-down link`() { + val s = state( + bonded = BondingState.BOND_NONE, + connected = PeripheralConnectionState.CONN_STATE_DISCONNECTED, + discovered = ServiceDiscoverState.DISCOVER_STATE_NONE, + notification = NotificationState.NOTIFICATION_NONE + ) + assertThat(s.isPairingFailed()).isTrue() + assertThat(s.copy(isNotificationEnabled = NotificationState.NOTIFICATION_DISABLED).isPairingFailed()).isFalse() + assertThat(s.copy(isConnected = PeripheralConnectionState.CONN_STATE_CONNECTED).isPairingFailed()).isFalse() + assertThat(s.copy(isEnabled = DeviceModuleState.DEVICE_STATE_OFF).isPairingFailed()).isFalse() + } + + @Test + fun `BleState supports value semantics`() { + assertThat(connectedState).isEqualTo(state()) + assertThat(connectedState.copy(isBonded = BondingState.BOND_NONE)).isNotEqualTo(connectedState) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/gatt/BleTransportGattConnectionTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/gatt/BleTransportGattConnectionTest.kt new file mode 100644 index 000000000000..106efa726b73 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/gatt/BleTransportGattConnectionTest.kt @@ -0,0 +1,262 @@ +package app.aaps.pump.carelevo.ble.gatt + +import app.aaps.core.interfaces.pump.ble.BleAdapter +import app.aaps.core.interfaces.pump.ble.BleGatt +import app.aaps.core.interfaces.pump.ble.BleScanner +import app.aaps.core.interfaces.pump.ble.BleTransport +import app.aaps.core.interfaces.pump.ble.BleTransportListener +import app.aaps.core.interfaces.pump.ble.PairingState +import app.aaps.core.interfaces.pump.ble.ScannedDevice +import app.aaps.pump.carelevo.ble.BleClientImpl +import app.aaps.pump.carelevo.ble.BleCommand +import app.aaps.pump.carelevo.ble.BleResponse +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.UUID +import kotlin.test.assertFailsWith + +/** + * Verifies [BleTransportGattConnection] honours the [GattConnection] contract when driven by the + * shared [BleTransport] listener callbacks, and — the real Phase-1 goal — that the unchanged + * [BleClientImpl] correlates a request/response end-to-end over the adapter. + * + * Uses `runTest` virtual time (no real waits), matching [app.aaps.pump.carelevo.ble.BleClientContractTest]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class BleTransportGattConnectionTest { + + private val writeUuid: UUID = UUID.fromString("e1b40002-ffc4-4daa-a49b-1c92f99072ab") + private val notifyUuid: UUID = UUID.fromString("e1b40003-ffc4-4daa-a49b-1c92f99072ab") + + private lateinit var transport: FakeBleTransport + + @BeforeEach + fun setUp() { + transport = FakeBleTransport() + } + + private fun TestScope.newAdapter(): BleTransportGattConnection = + BleTransportGattConnection(transport, writeUuid, notifyUuid, backgroundScope) + + @Test + fun `registers itself as the transport listener on construction`() = runTest { + val adapter = newAdapter() + assertThat(transport.capturedListener).isSameInstanceAs(adapter) + } + + @Test + fun `writeCharacteristic returns once the transport acks the write`() = runTest { + val adapter = newAdapter() + transport.onWrite = { transport.capturedListener?.onCharacteristicWritten() } + + adapter.writeCharacteristic(writeUuid, byteArrayOf(0x24, 0x01)) + + assertThat(transport.writes).hasSize(1) + assertThat(transport.writes.single()).isEqualTo(byteArrayOf(0x24, 0x01)) + } + + @Test + fun `writeCharacteristic on a not-connected transport fast-fails with GattWriteException`() = runTest { + val adapter = newAdapter() + // Mirrors CarelevoBleTransportImpl: a write with no connection synchronously reports disconnect. + transport.onWrite = { transport.capturedListener?.onConnectionStateChanged(false) } + + assertFailsWith { + adapter.writeCharacteristic(writeUuid, byteArrayOf(0x24)) + } + } + + @Test + fun `discoverServices completes on success`() = runTest { + val adapter = newAdapter() + transport.onDiscover = { transport.capturedListener?.onServicesDiscovered(true) } + adapter.discoverServices() // does not throw + } + + @Test + fun `discoverServices throws GattDiscoveryException on failure`() = runTest { + val adapter = newAdapter() + transport.onDiscover = { transport.capturedListener?.onServicesDiscovered(false) } + assertFailsWith { adapter.discoverServices() } + } + + @Test + fun `enableNotifications completes when the descriptor write is acked`() = runTest { + val adapter = newAdapter() + transport.onEnableNotifications = { transport.capturedListener?.onDescriptorWritten() } + adapter.enableNotifications(notifyUuid) // does not throw + } + + @Test + fun `characteristic notifications surface on events as GattEvent Notification`() = runTest { + val adapter = newAdapter() + val events = mutableListOf() + val collector = launch { adapter.events.collect { events += it } } + runCurrent() + + transport.capturedListener?.onCharacteristicChanged(byteArrayOf(0x84.toByte(), 0x00)) + runCurrent() + + assertThat(events).hasSize(1) + val notification = events.single() as GattEvent.Notification + assertThat(notification.uuid).isEqualTo(notifyUuid) + assertThat(notification.payload).isEqualTo(byteArrayOf(0x84.toByte(), 0x00)) + collector.cancel() + } + + @Test + fun `disconnect aborts an in-flight write`() = runTest { + val adapter = newAdapter() + // Write is submitted but never acked; a mid-flight disconnect must abort it. + transport.onWrite = { transport.capturedListener?.onConnectionStateChanged(false) } + + assertFailsWith { + adapter.writeCharacteristic(writeUuid, byteArrayOf(0x24)) + } + } + + @Test + fun `disconnect emits ConnectionStateChanged DISCONNECTED on events`() = runTest { + val adapter = newAdapter() + val events = mutableListOf() + val collector = launch { adapter.events.collect { events += it } } + runCurrent() + + transport.capturedListener?.onConnectionStateChanged(false) + runCurrent() + + val state = events.filterIsInstance().single() + assertThat(state.state).isEqualTo(GattConnState.DISCONNECTED) + collector.cancel() + } + + @Test + fun `BleClient correlates a request and response end-to-end over the adapter`() = runTest { + val adapter = newAdapter() + val client = BleClientImpl(adapter, writeUuid, notifyUuid, backgroundScope) + runCurrent() + + transport.onWrite = { + transport.capturedListener?.onCharacteristicWritten() + transport.capturedListener?.onCharacteristicChanged(byteArrayOf(0x84.toByte(), 0x00)) + } + + val response = client.request(EchoCommand(requestOpcode = 0x24, expectedResponseOpcode = 0x84.toByte())) + + assertThat(response.raw[0]).isEqualTo(0x84.toByte()) + assertThat(transport.writes.single()[0]).isEqualTo(0x24.toByte()) + } + + @Test + fun `writeCharacteristic after close fast-fails instead of hanging`() = runTest { + val adapter = newAdapter() + adapter.close() + // After close() the transport listener is null, so the not-connected callback can't abort; + // the closed-guard must fail fast rather than hang until the caller's withTimeout. + assertFailsWith { + adapter.writeCharacteristic(writeUuid, byteArrayOf(0x24)) + } + } + + @Test + fun `discoverServices after close fast-fails`() = runTest { + val adapter = newAdapter() + adapter.close() + assertFailsWith { adapter.discoverServices() } + } + + @Test + fun `close is idempotent`() = runTest { + val adapter = newAdapter() + adapter.close() + adapter.close() // must not throw + } + + @Test + fun `writeCharacteristic with a foreign characteristic uuid fails loudly`() = runTest { + val adapter = newAdapter() + val foreignUuid = UUID.fromString("00000000-0000-0000-0000-000000000000") + assertFailsWith { + adapter.writeCharacteristic(foreignUuid, byteArrayOf(0x24)) + } + } + + // ===== Fixtures ===== + + private data class EchoResponse(val raw: ByteArray) : BleResponse + + private class EchoCommand( + override val requestOpcode: Byte, + override val expectedResponseOpcode: Byte + ) : BleCommand { + + override fun encode(): ByteArray = byteArrayOf(requestOpcode) + override fun decode(responsePayload: ByteArray): EchoResponse = EchoResponse(responsePayload) + } + + /** Minimal in-memory [BleTransport]; the test drives async responses via [listener]. */ + private class FakeBleTransport : BleTransport { + + var capturedListener: BleTransportListener? = null + val writes = mutableListOf() + + /** Invoked synchronously inside `gatt.writeCharacteristic` to simulate the pump's reply. */ + var onWrite: (() -> Unit)? = null + + /** Invoked synchronously inside `gatt.discoverServices` to simulate the discovery result. */ + var onDiscover: (() -> Unit)? = null + + /** Invoked synchronously inside `gatt.enableNotifications` to simulate the CCCD-write ack. */ + var onEnableNotifications: (() -> Unit)? = null + + override val adapter: BleAdapter = object : BleAdapter { + override fun enable() {} + override fun getDeviceName(address: String): String? = null + override fun isDeviceBonded(address: String): Boolean = false + override fun createBond(address: String): Boolean = true + override fun removeBond(address: String) {} + } + + override val scanner: BleScanner = object : BleScanner { + override val scannedDevices = MutableSharedFlow() + override fun startScan() {} + override fun stopScan() {} + } + + override val gatt: BleGatt = object : BleGatt { + override fun connect(address: String): Boolean = true + override fun disconnect() {} + override fun close() {} + override fun discoverServices() { + onDiscover?.invoke() + } + override fun findCharacteristics(): Boolean = true + override fun enableNotifications() { + onEnableNotifications?.invoke() + } + override fun writeCharacteristic(data: ByteArray) { + writes += data + onWrite?.invoke() + } + } + + private val _pairingState = MutableStateFlow(PairingState()) + override val pairingState = _pairingState + + override fun updatePairingState(state: PairingState) { + _pairingState.value = state + } + + override fun setListener(listener: BleTransportListener?) { + this.capturedListener = listener + } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/gatt/FakeGattConnection.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/gatt/FakeGattConnection.kt new file mode 100644 index 000000000000..94ce0539b929 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ble/gatt/FakeGattConnection.kt @@ -0,0 +1,146 @@ +package app.aaps.pump.carelevo.ble.gatt + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import java.util.UUID + +/** + * In-memory [GattConnection] for tests. No Android / Robolectric dependencies. + * + * Designed for `runTest` + virtual time — all operations are suspending and play + * cooperatively with `TestScheduler`. Tests script peripheral behaviour via the + * test-side API below; the SUT uses only the [GattConnection] surface. + * + * Typical shape: + * ``` + * val gatt = FakeGattConnection() + * val client = BleClient(gatt) + * + * gatt.onNextWrite { w -> + * gatt.deliverNotification(NOTIFY_UUID, bolusAckPayload(seq = w.payload[1])) + * } + * + * val ack = client.request(StartBolus(seq = 1, units = 2.5)) + * assertThat(gatt.recordedWrites).hasSize(1) + * assertThat(gatt.recordedWrites.single().payload).isEqualTo(expectedBytes) + * ``` + * + * Gotcha: [Write.equals] uses the default data-class semantics, which for + * `ByteArray` is reference equality. Compare payload content explicitly with + * `contentEquals(...)` or assert on individual fields, not the whole `Write`. + */ +class FakeGattConnection : GattConnection { + + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + override val events: SharedFlow = _events.asSharedFlow() + + /** A single `writeCharacteristic` invocation, recorded for assertions. */ + data class Write( + val uuid: UUID, + val payload: ByteArray, + val withResponse: Boolean + ) + + private val _writes = mutableListOf() + + /** Every [writeCharacteristic] call in arrival order. Defensive copy. */ + val recordedWrites: List get() = _writes.toList() + + private val writeBehaviors = ArrayDeque Unit>() + private val writeOutcomes = ArrayDeque() + private var discoveryOutcome: Boolean = true + private var closed = false + + // ===== GattConnection implementation ===== + + override suspend fun writeCharacteristic( + uuid: UUID, + payload: ByteArray, + withResponse: Boolean + ) { + check(!closed) { "FakeGattConnection is closed" } + val write = Write(uuid, payload, withResponse) + _writes += write + + // Run scripted side-effect first (typically delivers a notification), then ack. + // Matches the common case of a pump responding before the BLE stack's ack, + // but note: real Android BLE makes **no** ordering guarantee between + // onCharacteristicChanged and onCharacteristicWrite — correctness of + // [BleClientImpl] must not depend on a specific order. Tests that need the + // reverse order can emit the notification from the test body after request(). + writeBehaviors.removeFirstOrNull()?.invoke(write) + + val outcome = writeOutcomes.removeFirstOrNull() ?: WriteOutcome.Success + _events.emit(GattEvent.WriteAck(uuid, ok = outcome is WriteOutcome.Success)) + if (outcome is WriteOutcome.Failure) { + throw GattWriteException("scripted: ${outcome.reason}") + } + } + + override suspend fun discoverServices() { + check(!closed) { "FakeGattConnection is closed" } + _events.emit(GattEvent.ServicesDiscovered(discoveryOutcome)) + if (!discoveryOutcome) throw GattDiscoveryException("scripted discovery failure") + } + + override suspend fun enableNotifications(uuid: UUID) { + check(!closed) { "FakeGattConnection is closed" } + // Intentionally a no-op for the fake. Tests deliver notifications via + // [deliverNotification] regardless of which UUIDs are "enabled". + } + + override fun close() { + closed = true + } + + // ===== Test-side scripting API ===== + + /** Emit a notification as if the peripheral sent it. */ + suspend fun deliverNotification(uuid: UUID, payload: ByteArray) { + _events.emit(GattEvent.Notification(uuid, payload)) + } + + /** Emit a connection-state transition. */ + suspend fun deliverConnectionState(state: GattConnState) { + _events.emit(GattEvent.ConnectionStateChanged(state)) + } + + /** + * Run [block] when the next [writeCharacteristic] arrives, after the write is + * recorded but before the [GattEvent.WriteAck] is emitted. Typical use: `block` + * calls [deliverNotification] to mimic the pump answering before the ack. + * + * Queued FIFO — multiple calls stack up for successive writes. + */ + fun onNextWrite(block: suspend (Write) -> Unit) { + writeBehaviors += block + } + + /** + * Script the next [writeCharacteristic] call to emit `WriteAck(ok = false)` and + * then throw [GattWriteException]. Queued FIFO — stacks for successive writes. + */ + fun scriptNextWriteFailure(reason: String = "scripted") { + writeOutcomes += WriteOutcome.Failure(reason) + } + + /** + * Script [discoverServices] to emit `ServicesDiscovered(ok = false)` and throw + * [GattDiscoveryException]. Latching — stays in effect until reset with + * [resetDiscoveryOutcome]. + */ + fun scriptDiscoveryFailure() { + discoveryOutcome = false + } + + /** Reset discovery outcome to the default (success). */ + fun resetDiscoveryOutcome() { + discoveryOutcome = true + } + + private sealed interface WriteOutcome { + data object Success : WriteOutcome + data class Failure(val reason: String) : WriteOutcome + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/command/CarelevoActivationCommandsTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/command/CarelevoActivationCommandsTest.kt new file mode 100644 index 000000000000..663345bf2e6b --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/command/CarelevoActivationCommandsTest.kt @@ -0,0 +1,251 @@ +package app.aaps.pump.carelevo.command + +import app.aaps.core.interfaces.queue.CustomCommand +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.io.Serializable + +/** + * Unit tests for the Carelevo activation [CustomCommand] marker classes in + * [app.aaps.pump.carelevo.command] (CarelevoActivationCommands.kt) and [CmdSafetyCheck]. + * + * These are pure, dependency-free marker commands routed through the AAPS CommandQueue: no + * executor delegation happens inside them, so the tests assert their public contract — + * [CustomCommand.statusDescription], stored constructor arguments, the [CustomCommand] / [Serializable] + * type contract, and Java-serialization round-trips (they are queued, therefore must serialize cleanly). + */ +internal class CarelevoActivationCommandsTest { + + /** Round-trip an object through Java serialization (the mechanism the CommandQueue relies on). */ + private fun roundTrip(value: T): T { + val bytes = ByteArrayOutputStream().also { bos -> + ObjectOutputStream(bos).use { it.writeObject(value) } + }.toByteArray() + ObjectInputStream(ByteArrayInputStream(bytes)).use { + @Suppress("UNCHECKED_CAST") + return it.readObject() as T + } + } + + // region statusDescription — no-arg markers + + @Test + fun `CmdNeedleCheck exposes NEEDLE CHECK status`() { + assertThat(CmdNeedleCheck().statusDescription).isEqualTo("NEEDLE CHECK") + } + + @Test + fun `CmdSetBasal exposes SET BASAL status`() { + assertThat(CmdSetBasal().statusDescription).isEqualTo("SET BASAL") + } + + @Test + fun `CmdAdditionalPriming exposes ADDITIONAL PRIMING status`() { + assertThat(CmdAdditionalPriming().statusDescription).isEqualTo("ADDITIONAL PRIMING") + } + + @Test + fun `CmdDiscard exposes DISCARD PATCH status`() { + assertThat(CmdDiscard().statusDescription).isEqualTo("DISCARD PATCH") + } + + @Test + fun `CmdPumpResume exposes PUMP RESUME status`() { + assertThat(CmdPumpResume().statusDescription).isEqualTo("PUMP RESUME") + } + + @Test + fun `CmdSafetyCheck exposes SAFETY CHECK status`() { + assertThat(CmdSafetyCheck().statusDescription).isEqualTo("SAFETY CHECK") + } + + // endregion + + // region statusDescription + stored args — parameterized markers + + @Test + fun `CmdPumpStop exposes PUMP STOP status and stores durationMin`() { + val cmd = CmdPumpStop(durationMin = 30) + assertThat(cmd.statusDescription).isEqualTo("PUMP STOP") + assertThat(cmd.durationMin).isEqualTo(30) + } + + @Test + fun `CmdPumpStop stores zero and negative durations verbatim`() { + assertThat(CmdPumpStop(0).durationMin).isEqualTo(0) + assertThat(CmdPumpStop(-1).durationMin).isEqualTo(-1) + } + + @Test + fun `CmdTimeZoneUpdate exposes TIMEZONE UPDATE status and stores insulinAmount`() { + val cmd = CmdTimeZoneUpdate(insulinAmount = 42) + assertThat(cmd.statusDescription).isEqualTo("TIMEZONE UPDATE") + assertThat(cmd.insulinAmount).isEqualTo(42) + } + + @Test + fun `CmdUpdateMaxBolus exposes UPDATE MAX BOLUS status and stores maxBolusDose`() { + val cmd = CmdUpdateMaxBolus(maxBolusDose = 12.5) + assertThat(cmd.statusDescription).isEqualTo("UPDATE MAX BOLUS") + assertThat(cmd.maxBolusDose).isEqualTo(12.5) + } + + @Test + fun `CmdUpdateLowInsulinNotice exposes UPDATE LOW INSULIN NOTICE status and stores hours`() { + val cmd = CmdUpdateLowInsulinNotice(hours = 6) + assertThat(cmd.statusDescription).isEqualTo("UPDATE LOW INSULIN NOTICE") + assertThat(cmd.hours).isEqualTo(6) + } + + @Test + fun `CmdUpdateExpiredThreshold exposes UPDATE EXPIRY THRESHOLD status and stores hours`() { + val cmd = CmdUpdateExpiredThreshold(hours = 4) + assertThat(cmd.statusDescription).isEqualTo("UPDATE EXPIRY THRESHOLD") + assertThat(cmd.hours).isEqualTo(4) + } + + @Test + fun `CmdUpdateBuzzer exposes UPDATE BUZZER status and stores on true`() { + val cmd = CmdUpdateBuzzer(on = true) + assertThat(cmd.statusDescription).isEqualTo("UPDATE BUZZER") + assertThat(cmd.on).isTrue() + } + + @Test + fun `CmdUpdateBuzzer stores on false`() { + assertThat(CmdUpdateBuzzer(on = false).on).isFalse() + } + + @Test + fun `CmdAlarmClear exposes ALARM CLEAR status and stores alarm fields`() { + val cmd = CmdAlarmClear( + alarmId = "alarm-1", + alarmType = AlarmType.ALERT, + alarmCause = AlarmCause.ALARM_ALERT_OUT_OF_INSULIN + ) + assertThat(cmd.statusDescription).isEqualTo("ALARM CLEAR") + assertThat(cmd.alarmId).isEqualTo("alarm-1") + assertThat(cmd.alarmType).isEqualTo(AlarmType.ALERT) + assertThat(cmd.alarmCause).isEqualTo(AlarmCause.ALARM_ALERT_OUT_OF_INSULIN) + } + + @Test + fun `CmdAlarmClearPatchDiscard exposes ALARM CLEAR PATCH DISCARD status and stores alarm fields`() { + val cmd = CmdAlarmClearPatchDiscard( + alarmId = "alarm-9", + alarmType = AlarmType.WARNING, + alarmCause = AlarmCause.ALARM_WARNING_PUMP_CLOGGED + ) + assertThat(cmd.statusDescription).isEqualTo("ALARM CLEAR PATCH DISCARD") + assertThat(cmd.alarmId).isEqualTo("alarm-9") + assertThat(cmd.alarmType).isEqualTo(AlarmType.WARNING) + assertThat(cmd.alarmCause).isEqualTo(AlarmCause.ALARM_WARNING_PUMP_CLOGGED) + } + + // endregion + + // region CustomCommand / Serializable type contract + + @Test + fun `every activation command is a CustomCommand`() { + val commands: List = listOf( + CmdNeedleCheck(), + CmdSetBasal(), + CmdAdditionalPriming(), + CmdDiscard(), + CmdPumpStop(30), + CmdPumpResume(), + CmdTimeZoneUpdate(1), + CmdUpdateMaxBolus(1.0), + CmdUpdateLowInsulinNotice(1), + CmdUpdateExpiredThreshold(1), + CmdUpdateBuzzer(true), + CmdAlarmClear("a", AlarmType.ALERT, AlarmCause.ALARM_ALERT_OUT_OF_INSULIN), + CmdAlarmClearPatchDiscard("a", AlarmType.WARNING, AlarmCause.ALARM_WARNING_PUMP_CLOGGED), + CmdSafetyCheck() + ) + commands.forEach { assertThat(it).isInstanceOf(CustomCommand::class.java) } + } + + @Test + fun `every activation command is Serializable`() { + val commands: List = listOf( + CmdNeedleCheck(), + CmdSetBasal(), + CmdAdditionalPriming(), + CmdDiscard(), + CmdPumpStop(30), + CmdPumpResume(), + CmdTimeZoneUpdate(1), + CmdUpdateMaxBolus(1.0), + CmdUpdateLowInsulinNotice(1), + CmdUpdateExpiredThreshold(1), + CmdUpdateBuzzer(true), + CmdAlarmClear("a", AlarmType.ALERT, AlarmCause.ALARM_ALERT_OUT_OF_INSULIN), + CmdAlarmClearPatchDiscard("a", AlarmType.WARNING, AlarmCause.ALARM_WARNING_PUMP_CLOGGED), + CmdSafetyCheck() + ) + commands.forEach { assertThat(it).isInstanceOf(Serializable::class.java) } + } + + // endregion + + // region serialization round-trips (queued commands must survive it) + + @Test + fun `CmdPumpStop survives a serialization round-trip preserving durationMin and status`() { + val restored = roundTrip(CmdPumpStop(durationMin = 45)) + assertThat(restored.durationMin).isEqualTo(45) + assertThat(restored.statusDescription).isEqualTo("PUMP STOP") + } + + @Test + fun `CmdUpdateMaxBolus survives a serialization round-trip preserving maxBolusDose`() { + val restored = roundTrip(CmdUpdateMaxBolus(maxBolusDose = 9.5)) + assertThat(restored.maxBolusDose).isEqualTo(9.5) + } + + @Test + fun `CmdUpdateBuzzer survives a serialization round-trip preserving on`() { + assertThat(roundTrip(CmdUpdateBuzzer(on = true)).on).isTrue() + assertThat(roundTrip(CmdUpdateBuzzer(on = false)).on).isFalse() + } + + @Test + fun `CmdAlarmClear survives a serialization round-trip preserving all fields`() { + val restored = roundTrip( + CmdAlarmClear( + alarmId = "alarm-77", + alarmType = AlarmType.ALERT, + alarmCause = AlarmCause.ALARM_ALERT_PATCH_EXPIRED_PHASE_2 + ) + ) + assertThat(restored.alarmId).isEqualTo("alarm-77") + assertThat(restored.alarmType).isEqualTo(AlarmType.ALERT) + assertThat(restored.alarmCause).isEqualTo(AlarmCause.ALARM_ALERT_PATCH_EXPIRED_PHASE_2) + assertThat(restored.statusDescription).isEqualTo("ALARM CLEAR") + } + + @Test + fun `CmdAlarmClearPatchDiscard survives a serialization round-trip preserving all fields`() { + val restored = roundTrip( + CmdAlarmClearPatchDiscard( + alarmId = "alarm-88", + alarmType = AlarmType.WARNING, + alarmCause = AlarmCause.ALARM_WARNING_PATCH_ERROR + ) + ) + assertThat(restored.alarmId).isEqualTo("alarm-88") + assertThat(restored.alarmType).isEqualTo(AlarmType.WARNING) + assertThat(restored.alarmCause).isEqualTo(AlarmCause.ALARM_WARNING_PATCH_ERROR) + assertThat(restored.statusDescription).isEqualTo("ALARM CLEAR PATCH DISCARD") + } + + // endregion +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/command/CarelevoActivationExecutorTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/command/CarelevoActivationExecutorTest.kt new file mode 100644 index 000000000000..93985299c300 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/command/CarelevoActivationExecutorTest.kt @@ -0,0 +1,771 @@ +package app.aaps.pump.carelevo.command + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.profile.Profile +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.queue.CustomCommand +import app.aaps.pump.carelevo.ble.BleResponse +import app.aaps.pump.carelevo.ble.CarelevoBleSession +import app.aaps.pump.carelevo.ble.commands.AlarmClearResponse +import app.aaps.pump.carelevo.ble.commands.InfusionThresholdResponse +import app.aaps.pump.carelevo.ble.commands.NoticeThresholdResponse +import app.aaps.pump.carelevo.ble.commands.PumpResumeResponse +import app.aaps.pump.carelevo.ble.commands.SafetyCheckCommand +import app.aaps.pump.carelevo.ble.commands.SafetyCheckResponse +import app.aaps.pump.carelevo.ble.commands.SimpleResultResponse +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.SafetyProgress +import app.aaps.pump.carelevo.domain.usecase.alarm.AlarmClearPatchDiscardUseCase +import app.aaps.pump.carelevo.domain.usecase.alarm.AlarmClearRequestUseCase +import app.aaps.pump.carelevo.domain.usecase.basal.CarelevoSetBasalProgramUseCase +import app.aaps.pump.carelevo.domain.usecase.infusion.CarelevoPumpResumeUseCase +import app.aaps.pump.carelevo.domain.usecase.infusion.CarelevoPumpStopUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchNeedleInsertionCheckUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchSafetyCheckUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoUpdateLowInsulinNoticeAmountUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoUpdateMaxBolusDoseUseCase +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.subjects.BehaviorSubject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.Optional +import javax.inject.Provider + +/** + * JVM unit tests for [CarelevoActivationExecutor]. Every op is driven through the public [CarelevoActivationExecutor.execute] + * entry point (the only public API besides the [CarelevoActivationExecutor.safetyProgress] flow). The + * coroutine [CarelevoBleSession] and the persistence-only use cases are mocked; suspend calls are stubbed + * with the module's `whenever { ... }` lambda form (see [app.aaps.pump.carelevo.CarelevoPumpPluginTestBase]). + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoActivationExecutorTest { + + @Mock lateinit var aapsLogger: AAPSLogger + @Mock lateinit var carelevoPatch: CarelevoPatch + @Mock lateinit var safetyCheckUseCase: CarelevoPatchSafetyCheckUseCase + @Mock lateinit var needleCheckUseCase: CarelevoPatchNeedleInsertionCheckUseCase + @Mock lateinit var setBasalUseCase: CarelevoSetBasalProgramUseCase + @Mock lateinit var pumpStopUseCase: CarelevoPumpStopUseCase + @Mock lateinit var pumpResumeUseCase: CarelevoPumpResumeUseCase + @Mock lateinit var updateMaxBolusDoseUseCase: CarelevoUpdateMaxBolusDoseUseCase + @Mock lateinit var updateLowInsulinNoticeAmountUseCase: CarelevoUpdateLowInsulinNoticeAmountUseCase + @Mock lateinit var alarmClearRequestUseCase: AlarmClearRequestUseCase + @Mock lateinit var alarmClearPatchDiscardUseCase: AlarmClearPatchDiscardUseCase + @Mock lateinit var bleSession: CarelevoBleSession + + private val pumpEnactResultProvider = Provider { FakePumpEnactResult() } + + private lateinit var sut: CarelevoActivationExecutor + + @BeforeEach + fun setUp() { + sut = CarelevoActivationExecutor( + aapsLogger = aapsLogger, + pumpEnactResultProvider = pumpEnactResultProvider, + carelevoPatch = carelevoPatch, + safetyCheckUseCase = safetyCheckUseCase, + needleCheckUseCase = needleCheckUseCase, + setBasalUseCase = setBasalUseCase, + pumpStopUseCase = pumpStopUseCase, + pumpResumeUseCase = pumpResumeUseCase, + updateMaxBolusDoseUseCase = updateMaxBolusDoseUseCase, + updateLowInsulinNoticeAmountUseCase = updateLowInsulinNoticeAmountUseCase, + alarmClearRequestUseCase = alarmClearRequestUseCase, + alarmClearPatchDiscardUseCase = alarmClearPatchDiscardUseCase, + bleSession = bleSession + ) + // Happy default: a patch address is stored. Individual "no address" tests re-stub to null. + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(ADDRESS) + } + + // region helpers --------------------------------------------------------------------------------- + + private fun stubProfilePresent() { + val profile = mock() + whenever(carelevoPatch.profile).thenReturn(BehaviorSubject.createDefault(Optional.of(profile))) + } + + private fun stubBasalPlan() { + whenever(setBasalUseCase.buildBasalProgramPlan(any())).thenReturn( + CarelevoSetBasalProgramUseCase.BasalProgramPlan(programs = List(3) { List(8) { 1.0 } }, segments = emptyList()) + ) + } + + /** Make the (suspend) generic [CarelevoBleSession.runSingle] return [response]. */ + private fun stubRunSingle(response: BleResponse) { + whenever { bleSession.runSingle(any(), any(), any()) }.thenReturn(response) + } + + /** Make the (suspend) generic [CarelevoBleSession.runSingle] throw. */ + private fun stubRunSingleThrows(message: String = BOOM) { + whenever { bleSession.runSingle(any(), any(), any()) }.thenAnswer { throw RuntimeException(message) } + } + + /** Drive the streaming safety check: feed [frames] to the executor's onFrame callback. */ + private fun stubSafetyFrames(vararg frames: SafetyCheckResponse) { + whenever { bleSession.runSafetyCheck(any(), any()) }.thenAnswer { inv -> + @Suppress("UNCHECKED_CAST") + val onFrame = inv.getArgument(1) as (SafetyCheckResponse) -> Unit + frames.forEach { onFrame(it) } + Unit + } + } + + private fun stubSafetyThrows(message: String = BOOM) { + whenever { bleSession.runSafetyCheck(any(), any()) }.thenAnswer { throw RuntimeException(message) } + } + + /** Live-collect [CarelevoActivationExecutor.safetyProgress]; Unconfined resumes the collector inline on emit. */ + private fun collectSafetyProgress(): Pair, Job> { + val emissions = mutableListOf() + val job = CoroutineScope(Dispatchers.Unconfined).launch { sut.safetyProgress.collect { emissions.add(it) } } + return emissions to job + } + + private fun progressFrame(code: Int = SafetyCheckCommand.REP_REQUEST) = SafetyCheckResponse(resultCode = code, insulinVolume = 10, durationSeconds = 100) + private fun successFrame() = SafetyCheckResponse(resultCode = SafetyCheckCommand.RESULT_SUCCESS, insulinVolume = 20, durationSeconds = 120) + private fun errorFrame(code: Int = 2) = SafetyCheckResponse(resultCode = code, insulinVolume = 0, durationSeconds = 0) + + // endregion -------------------------------------------------------------------------------------- + + // region execute dispatch + + @Test fun `execute returns null for an unknown custom command`() { + assertThat(sut.execute(mock())).isNull() + } + + // endregion + + // region setBasal + + @Test fun `setBasal returns profile-not-set when no profile is present`() { + whenever(carelevoPatch.profile).thenReturn(BehaviorSubject.createDefault(Optional.empty())) + val result = sut.execute(CmdSetBasal()) + assertThat(result?.success).isFalse() + assertThat(result?.enacted).isFalse() + assertThat(result?.comment).isEqualTo("profile not set") + } + + @Test fun `setBasal returns no-patch-address when profile present but address missing`() { + stubProfilePresent() + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + val result = sut.execute(CmdSetBasal()) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo("no patch address") + } + + @Test fun `setBasal success when programmed and persisted`() { + stubProfilePresent() + stubBasalPlan() + whenever { bleSession.runBasalProgram(any(), any(), any()) }.thenReturn(true) + whenever(setBasalUseCase.persistBasalProgram(any())).thenReturn(true) + val result = sut.execute(CmdSetBasal()) + assertThat(result?.success).isTrue() + assertThat(result?.enacted).isTrue() + } + + @Test fun `setBasal fails when programmed but persist fails`() { + stubProfilePresent() + stubBasalPlan() + whenever { bleSession.runBasalProgram(any(), any(), any()) }.thenReturn(true) + whenever(setBasalUseCase.persistBasalProgram(any())).thenReturn(false) + val result = sut.execute(CmdSetBasal()) + assertThat(result?.success).isFalse() + } + + @Test fun `setBasal fails when the program is rejected and does not persist`() { + stubProfilePresent() + stubBasalPlan() + whenever { bleSession.runBasalProgram(any(), any(), any()) }.thenReturn(false) + val result = sut.execute(CmdSetBasal()) + assertThat(result?.success).isFalse() + verify(setBasalUseCase, never()).persistBasalProgram(any()) + } + + @Test fun `setBasal returns failed result on exception`() { + stubProfilePresent() + stubBasalPlan() + whenever { bleSession.runBasalProgram(any(), any(), any()) }.thenAnswer { throw RuntimeException(BOOM) } + val result = sut.execute(CmdSetBasal()) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo(BOOM) + } + + // endregion + + // region pumpStop + + @Test fun `pumpStop returns no-patch-address when address missing`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + val result = sut.execute(CmdPumpStop(durationMin = 60)) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo("no patch address") + } + + @Test fun `pumpStop rejected when pump returns non-zero result`() { + stubRunSingle(SimpleResultResponse(resultCode = 3)) + val result = sut.execute(CmdPumpStop(durationMin = 60)) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo("stop result 3") + verify(pumpStopUseCase, never()).persistStopped(any()) + } + + @Test fun `pumpStop success when accepted and persisted`() { + stubRunSingle(SimpleResultResponse(resultCode = 0)) + whenever(pumpStopUseCase.persistStopped(any())).thenReturn(true) + val result = sut.execute(CmdPumpStop(durationMin = 60)) + assertThat(result?.success).isTrue() + assertThat(result?.enacted).isTrue() + } + + @Test fun `pumpStop fails when accepted but persist fails`() { + stubRunSingle(SimpleResultResponse(resultCode = 0)) + whenever(pumpStopUseCase.persistStopped(any())).thenReturn(false) + val result = sut.execute(CmdPumpStop(durationMin = 60)) + assertThat(result?.success).isFalse() + } + + @Test fun `pumpStop returns failed result on exception`() { + stubRunSingleThrows() + val result = sut.execute(CmdPumpStop(durationMin = 60)) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo(BOOM) + } + + // endregion + + // region pumpResume + + @Test fun `pumpResume returns no-patch-address when address missing`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + val result = sut.execute(CmdPumpResume()) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo("no patch address") + } + + @Test fun `pumpResume rejected when pump returns non-zero result`() { + stubRunSingle(PumpResumeResponse(resultCode = 5, mode = 1, subId = 0)) + val result = sut.execute(CmdPumpResume()) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo("resume result 5") + verify(pumpResumeUseCase, never()).persistResumed() + } + + @Test fun `pumpResume success when accepted and persisted`() { + stubRunSingle(PumpResumeResponse(resultCode = 0, mode = 1, subId = 0)) + whenever(pumpResumeUseCase.persistResumed()).thenReturn(true) + val result = sut.execute(CmdPumpResume()) + assertThat(result?.success).isTrue() + assertThat(result?.enacted).isTrue() + } + + @Test fun `pumpResume fails when accepted but persist fails`() { + stubRunSingle(PumpResumeResponse(resultCode = 0, mode = 1, subId = 0)) + whenever(pumpResumeUseCase.persistResumed()).thenReturn(false) + val result = sut.execute(CmdPumpResume()) + assertThat(result?.success).isFalse() + } + + @Test fun `pumpResume returns failed result on exception`() { + stubRunSingleThrows() + val result = sut.execute(CmdPumpResume()) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo(BOOM) + } + + // endregion + + // region buzzer + + @Test fun `buzzer returns no-patch-address when address missing`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + val result = sut.execute(CmdUpdateBuzzer(on = true)) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo("no patch address") + } + + @Test fun `buzzer success when pump accepts`() { + stubRunSingle(SimpleResultResponse(resultCode = 0)) + val result = sut.execute(CmdUpdateBuzzer(on = true)) + assertThat(result?.success).isTrue() + assertThat(result?.enacted).isTrue() + } + + @Test fun `buzzer fails when pump rejects`() { + stubRunSingle(SimpleResultResponse(resultCode = 1)) + val result = sut.execute(CmdUpdateBuzzer(on = false)) + assertThat(result?.success).isFalse() + } + + @Test fun `buzzer returns failed result on exception`() { + stubRunSingleThrows() + val result = sut.execute(CmdUpdateBuzzer(on = true)) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo(BOOM) + } + + // endregion + + // region updateMaxBolus + + @Test fun `maxBolus defers with local persist when a bolus is running`() { + whenever(updateMaxBolusDoseUseCase.isBolusRunning()).thenReturn(true) + whenever(updateMaxBolusDoseUseCase.persistMaxBolusDose(any(), any())).thenReturn(true) + val result = sut.execute(CmdUpdateMaxBolus(maxBolusDose = 10.0)) + assertThat(result?.success).isTrue() + verify(updateMaxBolusDoseUseCase).persistMaxBolusDose(10.0, synced = false) + } + + @Test fun `maxBolus deferred persist failure reports failure`() { + whenever(updateMaxBolusDoseUseCase.isBolusRunning()).thenReturn(true) + whenever(updateMaxBolusDoseUseCase.persistMaxBolusDose(any(), any())).thenReturn(false) + val result = sut.execute(CmdUpdateMaxBolus(maxBolusDose = 10.0)) + assertThat(result?.success).isFalse() + } + + @Test fun `maxBolus returns no-patch-address when not running and address missing`() { + whenever(updateMaxBolusDoseUseCase.isBolusRunning()).thenReturn(false) + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + val result = sut.execute(CmdUpdateMaxBolus(maxBolusDose = 10.0)) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo("no patch address") + } + + @Test fun `maxBolus success when pushed and persisted`() { + stubRunSingle(InfusionThresholdResponse(type = 1, resultCode = 0)) + whenever(updateMaxBolusDoseUseCase.persistMaxBolusDose(any(), any())).thenReturn(true) + val result = sut.execute(CmdUpdateMaxBolus(maxBolusDose = 10.0)) + assertThat(result?.success).isTrue() + verify(updateMaxBolusDoseUseCase).persistMaxBolusDose(10.0, synced = true) + } + + @Test fun `maxBolus fails but still persists deferred when pump rejects`() { + stubRunSingle(InfusionThresholdResponse(type = 1, resultCode = 4)) + whenever(updateMaxBolusDoseUseCase.persistMaxBolusDose(any(), any())).thenReturn(true) + val result = sut.execute(CmdUpdateMaxBolus(maxBolusDose = 10.0)) + assertThat(result?.success).isFalse() + verify(updateMaxBolusDoseUseCase).persistMaxBolusDose(10.0, synced = false) + } + + @Test fun `maxBolus fails when pushed but persist fails`() { + stubRunSingle(InfusionThresholdResponse(type = 1, resultCode = 0)) + whenever(updateMaxBolusDoseUseCase.persistMaxBolusDose(any(), any())).thenReturn(false) + val result = sut.execute(CmdUpdateMaxBolus(maxBolusDose = 10.0)) + assertThat(result?.success).isFalse() + } + + @Test fun `maxBolus keeps value deferred and reports failure on exception`() { + stubRunSingleThrows() + val result = sut.execute(CmdUpdateMaxBolus(maxBolusDose = 10.0)) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo(BOOM) + verify(updateMaxBolusDoseUseCase).persistMaxBolusDose(10.0, synced = false) + } + + // endregion + + // region updateLowInsulinNotice + + @Test fun `lowInsulinNotice returns no-patch-address when address missing`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + val result = sut.execute(CmdUpdateLowInsulinNotice(hours = 30)) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo("no patch address") + } + + @Test fun `lowInsulinNotice success when arrival persisted`() { + stubRunSingle(NoticeThresholdResponse(thresholdType = 0, resultCode = 0)) + whenever(updateLowInsulinNoticeAmountUseCase.persistLowInsulinNoticeAmount(any(), any())).thenReturn(true) + val result = sut.execute(CmdUpdateLowInsulinNotice(hours = 30)) + assertThat(result?.success).isTrue() + verify(updateLowInsulinNoticeAmountUseCase).persistLowInsulinNoticeAmount(30, synced = true) + } + + @Test fun `lowInsulinNotice fails when persist fails`() { + stubRunSingle(NoticeThresholdResponse(thresholdType = 0, resultCode = 0)) + whenever(updateLowInsulinNoticeAmountUseCase.persistLowInsulinNoticeAmount(any(), any())).thenReturn(false) + val result = sut.execute(CmdUpdateLowInsulinNotice(hours = 30)) + assertThat(result?.success).isFalse() + } + + @Test fun `lowInsulinNotice keeps value deferred and reports failure on exception`() { + stubRunSingleThrows() + val result = sut.execute(CmdUpdateLowInsulinNotice(hours = 30)) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo(BOOM) + verify(updateLowInsulinNoticeAmountUseCase).persistLowInsulinNoticeAmount(30, synced = false) + } + + // endregion + + // region updateExpiredThreshold + + @Test fun `expiryThreshold returns no-patch-address when address missing`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + val result = sut.execute(CmdUpdateExpiredThreshold(hours = 48)) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo("no patch address") + } + + @Test fun `expiryThreshold success when the frame arrives`() { + stubRunSingle(NoticeThresholdResponse(thresholdType = 1, resultCode = 0)) + val result = sut.execute(CmdUpdateExpiredThreshold(hours = 48)) + assertThat(result?.success).isTrue() + assertThat(result?.enacted).isTrue() + } + + @Test fun `expiryThreshold returns failed result on exception`() { + stubRunSingleThrows() + val result = sut.execute(CmdUpdateExpiredThreshold(hours = 48)) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo(BOOM) + } + + // endregion + + // region discard + + @Test fun `discard returns no-patch-address when address missing`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + val result = sut.execute(CmdDiscard()) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo("no patch address") + } + + @Test fun `discard tears down and succeeds when the stop write is accepted`() { + stubRunSingle(SimpleResultResponse(resultCode = 0)) + val result = sut.execute(CmdDiscard()) + assertThat(result?.success).isTrue() + assertThat(result?.enacted).isTrue() + verify(carelevoPatch).discardTeardown() + } + + @Test fun `discard does not tear down and fails when the stop write is rejected`() { + stubRunSingle(SimpleResultResponse(resultCode = 1)) + val result = sut.execute(CmdDiscard()) + assertThat(result?.success).isFalse() + verify(carelevoPatch, never()).discardTeardown() + } + + @Test fun `discard swallows the write exception and fails without teardown`() { + stubRunSingleThrows() + val result = sut.execute(CmdDiscard()) + assertThat(result?.success).isFalse() + verify(carelevoPatch, never()).discardTeardown() + } + + // endregion + + // region needleCheck + + @Test fun `needleCheck returns no-patch-address when address missing`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + val result = sut.execute(CmdNeedleCheck()) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo("no patch address") + } + + @Test fun `needleCheck success when inserted and persisted`() { + stubRunSingle(SimpleResultResponse(resultCode = 0)) + whenever(needleCheckUseCase.persistNeedleResult(any())).thenReturn(true) + val result = sut.execute(CmdNeedleCheck()) + assertThat(result?.success).isTrue() + verify(needleCheckUseCase).persistNeedleResult(true) + } + + @Test fun `needleCheck fails when inserted but persist fails`() { + stubRunSingle(SimpleResultResponse(resultCode = 0)) + whenever(needleCheckUseCase.persistNeedleResult(any())).thenReturn(false) + val result = sut.execute(CmdNeedleCheck()) + assertThat(result?.success).isFalse() + } + + @Test fun `needleCheck fails and persists not-inserted when pump reports non-zero`() { + stubRunSingle(SimpleResultResponse(resultCode = 2)) + whenever(needleCheckUseCase.persistNeedleResult(any())).thenReturn(true) + val result = sut.execute(CmdNeedleCheck()) + assertThat(result?.success).isFalse() + verify(needleCheckUseCase).persistNeedleResult(false) + } + + @Test fun `needleCheck returns failed result on exception`() { + stubRunSingleThrows() + val result = sut.execute(CmdNeedleCheck()) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo(BOOM) + } + + // endregion + + // region runSingleWrite (additionalPriming + timeZoneUpdate) + + @Test fun `additionalPriming returns no-patch-address when address missing`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + val result = sut.execute(CmdAdditionalPriming()) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo("no patch address") + } + + @Test fun `additionalPriming success when accepted`() { + stubRunSingle(SimpleResultResponse(resultCode = 0)) + val result = sut.execute(CmdAdditionalPriming()) + assertThat(result?.success).isTrue() + assertThat(result?.enacted).isTrue() + } + + @Test fun `additionalPriming fails when rejected`() { + stubRunSingle(SimpleResultResponse(resultCode = 7)) + val result = sut.execute(CmdAdditionalPriming()) + assertThat(result?.success).isFalse() + } + + @Test fun `additionalPriming returns failed result on exception`() { + stubRunSingleThrows() + val result = sut.execute(CmdAdditionalPriming()) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo(BOOM) + } + + @Test fun `timeZoneUpdate success when accepted`() { + stubRunSingle(SimpleResultResponse(resultCode = 0)) + val result = sut.execute(CmdTimeZoneUpdate(insulinAmount = 0)) + assertThat(result?.success).isTrue() + assertThat(result?.enacted).isTrue() + } + + @Test fun `timeZoneUpdate fails when rejected`() { + stubRunSingle(SimpleResultResponse(resultCode = 1)) + val result = sut.execute(CmdTimeZoneUpdate(insulinAmount = 0)) + assertThat(result?.success).isFalse() + } + + // endregion + + // region safetyCheck + + @Test fun `safetyCheck returns no-patch-address when address missing`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + val result = sut.execute(CmdSafetyCheck()) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo("no patch address") + } + + @Test fun `safetyCheck emits progress then success and returns success`() { + stubSafetyFrames(progressFrame(), successFrame()) + whenever(safetyCheckUseCase.persistSafetyChecked()).thenReturn(true) + val (emissions, job) = collectSafetyProgress() + + val result = sut.execute(CmdSafetyCheck()) + job.cancel() + + assertThat(result?.success).isTrue() + assertThat(result?.enacted).isTrue() + assertThat(emissions).hasSize(2) + assertThat(emissions[0]).isInstanceOf(SafetyProgress.Progress::class.java) + assertThat(emissions[1]).isInstanceOf(SafetyProgress.Success::class.java) + } + + @Test fun `safetyCheck accepts the REP_REQUEST1 progress code`() { + stubSafetyFrames(progressFrame(SafetyCheckCommand.REP_REQUEST1), successFrame()) + whenever(safetyCheckUseCase.persistSafetyChecked()).thenReturn(true) + val result = sut.execute(CmdSafetyCheck()) + assertThat(result?.success).isTrue() + } + + @Test fun `safetyCheck emits only one progress even with repeated progress frames`() { + stubSafetyFrames(progressFrame(), progressFrame(SafetyCheckCommand.REP_REQUEST1), successFrame()) + whenever(safetyCheckUseCase.persistSafetyChecked()).thenReturn(true) + val (emissions, job) = collectSafetyProgress() + + val result = sut.execute(CmdSafetyCheck()) + job.cancel() + + assertThat(result?.success).isTrue() + assertThat(emissions.count { it is SafetyProgress.Progress }).isEqualTo(1) + assertThat(emissions.last()).isInstanceOf(SafetyProgress.Success::class.java) + } + + @Test fun `safetyCheck fails and emits error when the success frame cannot be persisted`() { + stubSafetyFrames(successFrame()) + whenever(safetyCheckUseCase.persistSafetyChecked()).thenReturn(false) + val (emissions, job) = collectSafetyProgress() + + val result = sut.execute(CmdSafetyCheck()) + job.cancel() + + assertThat(result?.success).isFalse() + assertThat(emissions.single()).isInstanceOf(SafetyProgress.Error::class.java) + } + + @Test fun `safetyCheck fails and emits error on a terminal error frame`() { + stubSafetyFrames(errorFrame(code = 2)) + val (emissions, job) = collectSafetyProgress() + + val result = sut.execute(CmdSafetyCheck()) + job.cancel() + + assertThat(result?.success).isFalse() + assertThat(emissions.single()).isInstanceOf(SafetyProgress.Error::class.java) + verify(safetyCheckUseCase, never()).persistSafetyChecked() + } + + @Test fun `safetyCheck returns failed result and emits error on exception`() { + stubSafetyThrows() + val (emissions, job) = collectSafetyProgress() + + val result = sut.execute(CmdSafetyCheck()) + job.cancel() + + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo(BOOM) + assertThat(emissions.single()).isInstanceOf(SafetyProgress.Error::class.java) + } + + // endregion + + // region alarmClear + + @Test fun `alarmClear returns no-patch-address when address missing`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + val result = sut.execute(alarmClearCmd()) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo("no patch address") + } + + @Test fun `alarmClear success when cleared and persisted`() { + whenever(alarmClearRequestUseCase.commandAlarmType(any())).thenReturn(1) + stubRunSingle(AlarmClearResponse(subId = 0, cause = 1, resultCode = 0)) + whenever(alarmClearRequestUseCase.persistAlarmCleared(any())).thenReturn(true) + val result = sut.execute(alarmClearCmd()) + assertThat(result?.success).isTrue() + verify(alarmClearRequestUseCase).persistAlarmCleared(ALARM_ID) + } + + @Test fun `alarmClear fails when cleared but persist fails`() { + whenever(alarmClearRequestUseCase.commandAlarmType(any())).thenReturn(1) + stubRunSingle(AlarmClearResponse(subId = 0, cause = 1, resultCode = 0)) + whenever(alarmClearRequestUseCase.persistAlarmCleared(any())).thenReturn(false) + val result = sut.execute(alarmClearCmd()) + assertThat(result?.success).isFalse() + } + + @Test fun `alarmClear fails and skips persist when the pump rejects`() { + whenever(alarmClearRequestUseCase.commandAlarmType(any())).thenReturn(1) + stubRunSingle(AlarmClearResponse(subId = 0, cause = 1, resultCode = 9)) + val result = sut.execute(alarmClearCmd()) + assertThat(result?.success).isFalse() + verify(alarmClearRequestUseCase, never()).persistAlarmCleared(any()) + } + + @Test fun `alarmClear maps a null cause code to zero`() { + whenever(alarmClearRequestUseCase.commandAlarmType(any())).thenReturn(0) + stubRunSingle(AlarmClearResponse(subId = 0, cause = 0, resultCode = 0)) + whenever(alarmClearRequestUseCase.persistAlarmCleared(any())).thenReturn(true) + // ALARM_UNKNOWN carries a null code → executor falls back to cause = 0 (valid for AlarmClearCommand). + val result = sut.execute(CmdAlarmClear(ALARM_ID, AlarmCause.ALARM_UNKNOWN.alarmType, AlarmCause.ALARM_UNKNOWN)) + assertThat(result?.success).isTrue() + } + + @Test fun `alarmClear returns failed result on exception`() { + whenever(alarmClearRequestUseCase.commandAlarmType(any())).thenReturn(1) + stubRunSingleThrows() + val result = sut.execute(alarmClearCmd()) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo(BOOM) + } + + // endregion + + // region alarmClearPatchDiscard + + @Test fun `alarmClearPatchDiscard returns no-patch-address when address missing`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + val result = sut.execute(alarmDiscardCmd()) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo("no patch address") + } + + @Test fun `alarmClearPatchDiscard success when discarded and persisted`() { + stubRunSingle(SimpleResultResponse(resultCode = 0)) + whenever(alarmClearPatchDiscardUseCase.persistAlarmDiscarded(any())).thenReturn(true) + val result = sut.execute(alarmDiscardCmd()) + assertThat(result?.success).isTrue() + verify(alarmClearPatchDiscardUseCase).persistAlarmDiscarded(ALARM_ID) + } + + @Test fun `alarmClearPatchDiscard fails when discarded but persist fails`() { + stubRunSingle(SimpleResultResponse(resultCode = 0)) + whenever(alarmClearPatchDiscardUseCase.persistAlarmDiscarded(any())).thenReturn(false) + val result = sut.execute(alarmDiscardCmd()) + assertThat(result?.success).isFalse() + } + + @Test fun `alarmClearPatchDiscard fails and skips persist when the pump rejects`() { + stubRunSingle(SimpleResultResponse(resultCode = 6)) + val result = sut.execute(alarmDiscardCmd()) + assertThat(result?.success).isFalse() + verify(alarmClearPatchDiscardUseCase, never()).persistAlarmDiscarded(any()) + } + + @Test fun `alarmClearPatchDiscard returns failed result on exception`() { + stubRunSingleThrows() + val result = sut.execute(alarmDiscardCmd()) + assertThat(result?.success).isFalse() + assertThat(result?.comment).isEqualTo(BOOM) + } + + // endregion + + private fun alarmClearCmd() = CmdAlarmClear(ALARM_ID, AlarmCause.ALARM_ALERT_OUT_OF_INSULIN.alarmType, AlarmCause.ALARM_ALERT_OUT_OF_INSULIN) + private fun alarmDiscardCmd() = CmdAlarmClearPatchDiscard(ALARM_ID, AlarmCause.ALARM_WARNING_PUMP_CLOGGED.alarmType, AlarmCause.ALARM_WARNING_PUMP_CLOGGED) + + private companion object { + + private const val ADDRESS = "aa:bb:cc:dd:ee:ff" + private const val ALARM_ID = "alarm-1" + private const val BOOM = "boom" + } + + /** Minimal [PumpEnactResult] so the provider returns a fresh, real result object per op. */ + private class FakePumpEnactResult : PumpEnactResult { + + override var success: Boolean = false + override var enacted: Boolean = false + override var comment: String = "" + override var duration: Int = -1 + override var absolute: Double = -1.0 + override var percent: Int = -1 + override var isPercent: Boolean = false + override var isTempCancel: Boolean = false + override var bolusDelivered: Double = 0.0 + override var queued: Boolean = false + + override fun success(success: Boolean): PumpEnactResult = apply { this.success = success } + override fun enacted(enacted: Boolean): PumpEnactResult = apply { this.enacted = enacted } + override fun comment(comment: String): PumpEnactResult = apply { this.comment = comment } + override fun comment(comment: Int): PumpEnactResult = apply { this.comment = comment.toString() } + override fun duration(duration: Int): PumpEnactResult = apply { this.duration = duration } + override fun absolute(absolute: Double): PumpEnactResult = apply { this.absolute = absolute } + override fun percent(percent: Int): PumpEnactResult = apply { this.percent = percent } + override fun isPercent(isPercent: Boolean): PumpEnactResult = apply { this.isPercent = isPercent } + override fun isTempCancel(isTempCancel: Boolean): PumpEnactResult = apply { this.isTempCancel = isTempCancel } + override fun bolusDelivered(bolusDelivered: Double): PumpEnactResult = apply { this.bolusDelivered = bolusDelivered } + override fun queued(queued: Boolean): PumpEnactResult = apply { this.queued = queued } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/CarelevoAlarmActionHandlerTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/CarelevoAlarmActionHandlerTest.kt new file mode 100644 index 000000000000..5adcf8bb7fff --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/CarelevoAlarmActionHandlerTest.kt @@ -0,0 +1,185 @@ +package app.aaps.pump.carelevo.common + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.pump.carelevo.coordinator.CarelevoAlarmClearCoordinator +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.usecase.alarm.CarelevoAlarmInfoUseCase +import app.aaps.pump.carelevo.presentation.model.AlarmEvent +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.Optional + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoAlarmActionHandlerTest { + + @Mock lateinit var aapsLogger: AAPSLogger + @Mock lateinit var aapsSchedulers: AapsSchedulers + @Mock lateinit var commandQueue: CommandQueue + @Mock lateinit var alarmUseCase: CarelevoAlarmInfoUseCase + @Mock lateinit var alarmClearCoordinator: CarelevoAlarmClearCoordinator + + private lateinit var sut: CarelevoAlarmActionHandler + + private fun alarm( + id: String = "alarm-1", + cause: AlarmCause = AlarmCause.ALARM_ALERT_OUT_OF_INSULIN, + createdAt: String = "2026-07-16T10:00:00" + ): CarelevoAlarmInfo = + CarelevoAlarmInfo( + alarmId = id, + alarmType = cause.alarmType, + cause = cause, + value = null, + createdAt = createdAt, + updatedAt = createdAt, + isAcknowledged = false + ) + + @BeforeEach + fun setUp() { + whenever(aapsSchedulers.io).thenReturn(Schedulers.trampoline()) + whenever(aapsSchedulers.main).thenReturn(Schedulers.trampoline()) + whenever(alarmUseCase.acknowledgeAlarm(any())).thenReturn(Completable.complete()) + whenever(alarmUseCase.clearAlarms()).thenReturn(Completable.complete()) + sut = CarelevoAlarmActionHandler(aapsLogger, aapsSchedulers, commandQueue, alarmUseCase, alarmClearCoordinator) + } + + /** Await an asynchronous (Dispatchers.IO-launched) state change with a bounded timeout. */ + private fun awaitCondition(timeoutMs: Long = 5_000, condition: () -> Boolean) = runBlocking { + withTimeout(timeoutMs) { + while (!condition()) delay(20) + } + } + + @Test + fun `loadActiveAlarms fills the queue sorted by severity tier then creation time`() { + val notice = alarm(id = "notice", cause = AlarmCause.ALARM_NOTICE_LOW_INSULIN, createdAt = "2026-07-16T08:00:00") + val warningLate = alarm(id = "warning-late", cause = AlarmCause.ALARM_WARNING_PUMP_CLOGGED, createdAt = "2026-07-16T09:00:00") + val warningEarly = alarm(id = "warning-early", cause = AlarmCause.ALARM_WARNING_LOW_INSULIN, createdAt = "2026-07-16T08:30:00") + whenever(alarmUseCase.getAlarmsOnce()).thenReturn(Single.just(Optional.of(listOf(notice, warningLate, warningEarly)))) + + sut.loadActiveAlarms() + + assertThat(sut.alarmQueue.value.map { it.alarmId }) + .containsExactly("warning-early", "warning-late", "notice") + .inOrder() + } + + @Test + fun `acknowledgeAndRemoveAlarm persists the removal AND drops the queue entry`() { + whenever(alarmUseCase.getAlarmsOnce()).thenReturn(Single.just(Optional.of(listOf(alarm())))) + sut.loadActiveAlarms() + assertThat(sut.alarmQueue.value).hasSize(1) + + sut.acknowledgeAndRemoveAlarm("alarm-1") + + // Without the persist a "cleared" alarm resurrects on the next cold load. + verify(alarmUseCase).acknowledgeAlarm(eq("alarm-1")) + assertThat(sut.alarmQueue.value).isEmpty() + } + + @Test + fun `acknowledgeAndRemoveAlarm still drops the queue entry when the persist fails`() { + whenever(alarmUseCase.acknowledgeAlarm(any())).thenReturn(Completable.error(IllegalStateException("boom"))) + whenever(alarmUseCase.getAlarmsOnce()).thenReturn(Single.just(Optional.of(listOf(alarm())))) + sut.loadActiveAlarms() + + sut.acknowledgeAndRemoveAlarm("alarm-1") + + assertThat(sut.alarmQueue.value).isEmpty() + } + + @Test + fun `clearAllAlarms persists the wipe and empties the queue`() { + whenever(alarmUseCase.getAlarmsOnce()).thenReturn(Single.just(Optional.of(listOf(alarm(), alarm(id = "alarm-2"))))) + sut.loadActiveAlarms() + + sut.clearAllAlarms() + + verify(alarmUseCase).clearAlarms() + assertThat(sut.alarmQueue.value).isEmpty() + } + + @Test + fun `confirmed clearable alert is cleared on the patch then acknowledged`() { + whenever { alarmClearCoordinator.clearAlarmOnPatch(any()) }.thenReturn(true) + whenever(alarmUseCase.getAlarmsOnce()).thenReturn(Single.just(Optional.of(listOf(alarm())))) + sut.loadActiveAlarms() + + sut.triggerEvent(AlarmEvent.ClearAlarm(alarm())) + + awaitCondition { sut.alarmQueue.value.isEmpty() } + verifyBlocking(alarmClearCoordinator) { clearAlarmOnPatch(any()) } + verify(alarmUseCase).acknowledgeAlarm(eq("alarm-1")) + } + + @Test + fun `failed patch clear falls back to the local acknowledge path`() { + whenever { alarmClearCoordinator.clearAlarmOnPatch(any()) }.thenReturn(false) + whenever(alarmUseCase.getAlarmsOnce()).thenReturn(Single.just(Optional.of(listOf(alarm())))) + sut.loadActiveAlarms() + + sut.triggerEvent(AlarmEvent.ClearAlarm(alarm())) + + awaitCondition { sut.alarmQueue.value.isEmpty() } + verify(alarmUseCase).acknowledgeAlarm(eq("alarm-1")) + } + + @Test + fun `informational notice is acknowledged locally without any patch operation`() { + val notice = alarm(id = "lgs", cause = AlarmCause.ALARM_NOTICE_LGS_START) + whenever(alarmUseCase.getAlarmsOnce()).thenReturn(Single.just(Optional.of(listOf(notice)))) + sut.loadActiveAlarms() + + sut.triggerEvent(AlarmEvent.ClearAlarm(notice)) + + awaitCondition { sut.alarmQueue.value.isEmpty() } + verifyBlocking(alarmClearCoordinator, never()) { clearAlarmOnPatch(any()) } + verifyBlocking(alarmClearCoordinator, never()) { discardOnAlarm(any()) } + verify(alarmUseCase).acknowledgeAlarm(eq("lgs")) + } + + @Test + fun `warning-tier alarm routes to the patch discard flow`() { + whenever(alarmClearCoordinator.isPatchReachable()).thenReturn(true) + whenever { alarmClearCoordinator.discardOnAlarm(any()) }.thenReturn(true) + // The real coordinator runs the completion callback after unbond+flush; mirror that so the + // handler's queue-clearing continuation executes. + doAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + (invocation.arguments[0] as () -> Unit).invoke() + }.whenever(alarmClearCoordinator).forceQuitTeardown(any()) + val warning = alarm(id = "clog", cause = AlarmCause.ALARM_WARNING_PUMP_CLOGGED) + whenever(alarmUseCase.getAlarmsOnce()).thenReturn(Single.just(Optional.of(listOf(warning)))) + sut.loadActiveAlarms() + + sut.triggerEvent(AlarmEvent.ClearAlarm(warning)) + + awaitCondition { sut.alarmQueue.value.isEmpty() } + verifyBlocking(alarmClearCoordinator) { discardOnAlarm(any()) } + verify(alarmClearCoordinator).forceQuitTeardown(any()) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/CarelevoAlarmNotifierTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/CarelevoAlarmNotifierTest.kt new file mode 100644 index 000000000000..4d2c2301b1e6 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/CarelevoAlarmNotifierTest.kt @@ -0,0 +1,465 @@ +package app.aaps.pump.carelevo.common + +import android.app.NotificationManager as AndroidNotificationManager +import android.content.Context +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.notifications.NotificationAction +import app.aaps.core.interfaces.notifications.NotificationId +import app.aaps.core.interfaces.notifications.NotificationLevel +import app.aaps.core.interfaces.notifications.NotificationManager +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.ui.R as CoreUiR +import app.aaps.pump.carelevo.common.keys.CarelevoIntPreferenceKey +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.presentation.model.AlarmEvent +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.PublishSubject +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowNotificationManager + +/** + * Robolectric unit tests for [CarelevoAlarmNotifier]. + * + * [CarelevoAlarmNotifier] takes a real [Context] and calls into the Android framework directly — + * `context.getString(...)`, `PendingIntent.getActivity(...)`, `NotificationCompat.Builder(...)`, + * `HtmlCompat.fromHtml(...)`, `ProcessLifecycleOwner`, and the system [AndroidNotificationManager] + * (via `getSystemService`). Under a plain JVM Mockito test those calls returned defaulted/null + * values (e.g. `getString` → null → NPE), so this suite runs under [RobolectricTestRunner] with a + * REAL application [Context]: string formatting, HTML parsing, PendingIntents and system + * notifications all execute for real. + * + * Only the non-Android collaborators are mocked ([aapsLogger], [aapsSchedulers], [dateUtil], the + * AAPS [NotificationManager] interface, [sp], [alarmActionHandler]). Assertions are made against: + * - the mocked AAPS [NotificationManager] (the in-app "top notification" cards), + * - the [CarelevoAlarmNotifier.alarms] StateFlow and the `onAlarmsUpdated` callback, + * - Robolectric's [ShadowNotificationManager] for the system-tray notifications and channel. + * + * The schedulers are stubbed to [Schedulers.trampoline] so every Rx emission is delivered + * synchronously on the test thread, keeping the tests deterministic. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class CarelevoAlarmNotifierTest { + + // REAL application context (getString / PendingIntent / NotificationCompat all work). + private val context: Context = RuntimeEnvironment.getApplication() + + private lateinit var aapsLogger: AAPSLogger + private lateinit var aapsSchedulers: AapsSchedulers + private lateinit var dateUtil: DateUtil + private lateinit var notificationManager: NotificationManager + private lateinit var sp: SP + private lateinit var alarmActionHandler: CarelevoAlarmActionHandler + + private lateinit var sut: CarelevoAlarmNotifier + + // Must match the private channelId in the SUT. + private val channelId = "carelevo_alarm_channel" + + private fun alarm( + cause: AlarmCause, + value: Int? = null, + id: String = "alarm-1" + ): CarelevoAlarmInfo = + CarelevoAlarmInfo( + alarmId = id, + alarmType = cause.alarmType, + cause = cause, + value = value, + createdAt = "2026-07-16T10:00:00", + updatedAt = "2026-07-16T10:00:00", + isAcknowledged = false + ) + + @Before + fun setUp() { + aapsLogger = mock() + aapsSchedulers = mock() + dateUtil = mock() + notificationManager = mock() + sp = mock() + alarmActionHandler = mock() + + whenever(aapsSchedulers.io).thenReturn(Schedulers.trampoline()) + whenever(aapsSchedulers.main).thenReturn(Schedulers.trampoline()) + whenever(dateUtil.now()).thenReturn(1_000_000L) + + sut = CarelevoAlarmNotifier( + context = context, + aapsLogger = aapsLogger, + aapsSchedulers = aapsSchedulers, + dateUtil = dateUtil, + notificationManager = notificationManager, + sp = sp, + alarmActionHandler = alarmActionHandler + ) + } + + // ---- helpers ------------------------------------------------------------------------------ + + /** Verify one post() to the mocked AAPS NotificationManager (the 8-arg date/validTo overload). */ + private fun verifyPosted(level: NotificationLevel, critical: Boolean, count: Int = 1) { + if (critical) { + verify(notificationManager, times(count)).post( + eq(NotificationId.CARELEVO_PATCH_ALERT), any(), eq(level), + any(), any(), eq(CoreUiR.raw.error), any>(), anyOrNull() + ) + } else { + verify(notificationManager, times(count)).post( + eq(NotificationId.CARELEVO_PATCH_ALERT), any(), eq(level), + any(), any(), isNull(), any>(), anyOrNull() + ) + } + } + + /** Capture the `text` argument of the single post() to the AAPS NotificationManager. */ + private fun capturePostedText(): String { + val textCaptor = argumentCaptor() + verify(notificationManager).post( + any(), textCaptor.capture(), any(), any(), any(), anyOrNull(), + any>(), anyOrNull() + ) + return textCaptor.firstValue + } + + private fun systemNmShadow(): ShadowNotificationManager = + Shadows.shadowOf(context.getSystemService(AndroidNotificationManager::class.java)) + + /** Create the system notification channel (needed before posting system-tray notifications). */ + private fun createChannel() { + whenever(alarmActionHandler.observeAlarms()).thenReturn(Observable.empty>()) + sut.startObserving { } + } + + // ---- simple state / accessors ------------------------------------------------------------- + + @Test + fun `alarms state flow starts empty`() { + assertThat(sut.alarms.value).isEmpty() + } + + @Test + fun `alarmHostActive defaults to false and is settable`() { + assertThat(sut.alarmHostActive).isFalse() + sut.alarmHostActive = true + assertThat(sut.alarmHostActive).isTrue() + sut.alarmHostActive = false + assertThat(sut.alarmHostActive).isFalse() + } + + @Test + fun `isInForeground can be read without crashing`() { + // ProcessLifecycleOwner resolves under Robolectric; the exact foreground state depends on the + // Robolectric process-lifecycle setup, so only assert the accessor returns a Boolean, no throw. + assertThat(listOf(true, false)).contains(sut.isInForeground) + } + + // ---- showTopNotification (in-app AAPS cards) ---------------------------------------------- + + @Test + fun `showTopNotification with an empty list only dismisses the previous cards`() { + sut.showTopNotification(emptyList()) + + verify(notificationManager).dismiss(eq(NotificationId.CARELEVO_PATCH_ALERT)) + verify(notificationManager, never()).post( + any(), any(), any(), any(), any(), anyOrNull(), any>(), anyOrNull() + ) + } + + @Test + fun `showTopNotification dismisses previous cards before re-posting`() { + sut.showTopNotification(listOf(alarm(AlarmCause.ALARM_NOTICE_LGS_START))) + + verify(notificationManager).dismiss(eq(NotificationId.CARELEVO_PATCH_ALERT)) + } + + @Test + fun `showTopNotification for a critical WARNING posts URGENT with the alarm sound`() { + sut.showTopNotification(listOf(alarm(AlarmCause.ALARM_WARNING_PUMP_CLOGGED))) + + verifyPosted(NotificationLevel.URGENT, critical = true) + } + + @Test + fun `showTopNotification for a critical ALERT posts URGENT with the alarm sound`() { + sut.showTopNotification(listOf(alarm(AlarmCause.ALARM_ALERT_OUT_OF_INSULIN))) + + verifyPosted(NotificationLevel.URGENT, critical = true) + } + + @Test + fun `showTopNotification for a non-critical NOTICE posts NORMAL and silent`() { + sut.showTopNotification(listOf(alarm(AlarmCause.ALARM_NOTICE_LGS_START))) + + verifyPosted(NotificationLevel.NORMAL, critical = false) + } + + @Test + fun `showTopNotification posts one card per alarm`() { + sut.showTopNotification( + listOf( + alarm(AlarmCause.ALARM_NOTICE_LGS_START, id = "a"), + alarm(AlarmCause.ALARM_WARNING_PUMP_CLOGGED, id = "b") + ) + ) + + verify(notificationManager).dismiss(eq(NotificationId.CARELEVO_PATCH_ALERT)) + verify(notificationManager, times(2)).post( + eq(NotificationId.CARELEVO_PATCH_ALERT), any(), any(), + any(), any(), anyOrNull(), any>(), anyOrNull() + ) + } + + @Test + fun `showTopNotification builds a non-blank card text from real resources`() { + sut.showTopNotification(listOf(alarm(AlarmCause.ALARM_NOTICE_LGS_START))) + + // With a real Context, getString + HtmlCompat resolve to actual text (no more NPE / "Stub!"). + assertThat(capturePostedText()).isNotEmpty() + } + + @Test + fun `showTopNotification action clears the alarm through the action handler`() { + val info = alarm(AlarmCause.ALARM_NOTICE_LGS_START) + sut.showTopNotification(listOf(info)) + + val actionsCaptor = argumentCaptor>() + verify(notificationManager).post( + any(), any(), any(), any(), any(), anyOrNull(), actionsCaptor.capture(), anyOrNull() + ) + actionsCaptor.firstValue.first().action.invoke() + + verify(alarmActionHandler).triggerEvent(any()) + } + + @Test + fun `showTopNotification for low-insulin notice reads the low-insulin reminder preference`() { + whenever(sp.getInt(eq(CarelevoIntPreferenceKey.CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS.key), any())) + .thenReturn(25) + + sut.showTopNotification(listOf(alarm(AlarmCause.ALARM_NOTICE_LOW_INSULIN))) + + verify(sp).getInt(eq(CarelevoIntPreferenceKey.CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS.key), eq(30)) + verifyPosted(NotificationLevel.NORMAL, critical = false) + } + + @Test + fun `showTopNotification for out-of-insulin alert reads the low-insulin reminder preference`() { + whenever(sp.getInt(eq(CarelevoIntPreferenceKey.CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS.key), any())) + .thenReturn(25) + + sut.showTopNotification(listOf(alarm(AlarmCause.ALARM_ALERT_OUT_OF_INSULIN))) + + verify(sp).getInt(eq(CarelevoIntPreferenceKey.CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS.key), eq(30)) + verifyPosted(NotificationLevel.URGENT, critical = true) + } + + @Test + fun `showTopNotification for patch-expired notice reads the patch-expiration preference and formats text`() { + whenever(sp.getInt(eq(CarelevoIntPreferenceKey.CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS.key), any())) + .thenReturn(50) + + sut.showTopNotification(listOf(alarm(AlarmCause.ALARM_NOTICE_PATCH_EXPIRED))) + + // 50h -> 2 days 2 hours split fed into the description template; the formatted text is real. + verify(sp).getInt(eq(CarelevoIntPreferenceKey.CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS.key), eq(116)) + val text = capturePostedText() + assertThat(text).isNotEmpty() + // Locale-independent: the "%s days %s hours" template embeds the digits 2 and 2. + assertThat(text).contains("2") + } + + @Test + fun `showTopNotification for bg-check notice formats hours and minutes`() { + // 125 min -> 2 hrs 5 min branch of formatBgCheckDuration; must not throw against real resources. + sut.showTopNotification(listOf(alarm(AlarmCause.ALARM_NOTICE_BG_CHECK, value = 125))) + + val text = capturePostedText() + assertThat(text).contains("2") + assertThat(text).contains("5") + verifyPosted(NotificationLevel.NORMAL, critical = false) + } + + @Test + fun `showTopNotification for bg-check notice formats whole hours`() { + // 120 min -> whole-hours branch (2 hrs). + sut.showTopNotification(listOf(alarm(AlarmCause.ALARM_NOTICE_BG_CHECK, value = 120))) + + assertThat(capturePostedText()).contains("2") + verifyPosted(NotificationLevel.NORMAL, critical = false) + } + + @Test + fun `showTopNotification for bg-check notice formats minutes only`() { + // 45 min -> minutes-only branch. + sut.showTopNotification(listOf(alarm(AlarmCause.ALARM_NOTICE_BG_CHECK, value = 45))) + + assertThat(capturePostedText()).contains("45") + verifyPosted(NotificationLevel.NORMAL, critical = false) + } + + @Test + fun `showTopNotification for a cause with no description args posts without reading preferences`() { + sut.showTopNotification(listOf(alarm(AlarmCause.ALARM_WARNING_NEEDLE_INSERTION_ERROR))) + + verify(sp, never()).getInt(any(), any()) + verifyPosted(NotificationLevel.URGENT, critical = true) + } + + // ---- startObserving / stopObserving / channel --------------------------------------------- + + @Test + fun `startObserving creates the system notification channel`() { + whenever(alarmActionHandler.observeAlarms()).thenReturn(Observable.empty>()) + + sut.startObserving { } + + val realNm = context.getSystemService(AndroidNotificationManager::class.java) + assertThat(realNm.getNotificationChannel(channelId)).isNotNull() + } + + @Test + fun `startObserving forwards emitted alarms to the callback and the state flow`() { + val emitted = listOf(alarm(AlarmCause.ALARM_NOTICE_LGS_START)) + whenever(alarmActionHandler.observeAlarms()).thenReturn(Observable.just(emitted)) + var received: List? = null + + sut.startObserving { received = it } + + assertThat(received).isEqualTo(emitted) + assertThat(sut.alarms.value).isEqualTo(emitted) + } + + @Test + fun `startObserving logs and does not crash when the alarm stream errors`() { + whenever(alarmActionHandler.observeAlarms()) + .thenReturn(Observable.error>(RuntimeException("boom"))) + var callbackInvoked = false + + sut.startObserving { callbackInvoked = true } + + assertThat(callbackInvoked).isFalse() + assertThat(sut.alarms.value).isEmpty() + verify(aapsLogger).error(eq(LTag.PUMPCOMM), any()) + } + + @Test + fun `stopObserving disposes the subscription so later emissions are ignored`() { + val subject = PublishSubject.create>() + whenever(alarmActionHandler.observeAlarms()).thenReturn(subject) + var callbackCount = 0 + + sut.startObserving { callbackCount++ } + subject.onNext(emptyList()) + assertThat(callbackCount).isEqualTo(1) + + sut.stopObserving() + subject.onNext(emptyList()) + + // No further delivery after disposal. + assertThat(callbackCount).isEqualTo(1) + } + + // ---- refreshAlarms ------------------------------------------------------------------------ + + @Test + fun `refreshAlarms updates the state flow from a one-shot load`() { + val loaded = listOf(alarm(AlarmCause.ALARM_NOTICE_LGS_START)) + whenever(alarmActionHandler.getAlarmsOnce()).thenReturn(Single.just(loaded)) + + sut.refreshAlarms() + + assertThat(sut.alarms.value).isEqualTo(loaded) + } + + @Test + fun `refreshAlarms logs and does not crash when the load errors`() { + whenever(alarmActionHandler.getAlarmsOnce()) + .thenReturn(Single.error>(RuntimeException("boom"))) + + sut.refreshAlarms() + + assertThat(sut.alarms.value).isEmpty() + verify(aapsLogger).error(eq(LTag.PUMPCOMM), any()) + } + + // ---- showNotification (system-tray, asserted via ShadowNotificationManager) --------------- + + @Test + fun `showNotification posts a system-tray notification`() { + createChannel() + + sut.showNotification(alarm(AlarmCause.ALARM_WARNING_PUMP_CLOGGED)) + + assertThat(systemNmShadow().allNotifications).isNotEmpty() + } + + @Test + fun `showNotification for out-of-insulin embeds the remaining amount without crashing`() { + createChannel() + + sut.showNotification(alarm(AlarmCause.ALARM_ALERT_OUT_OF_INSULIN, value = 15)) + + assertThat(systemNmShadow().allNotifications).isNotEmpty() + } + + @Test + fun `showNotification for patch-expired splits days and hours without crashing`() { + createChannel() + + sut.showNotification(alarm(AlarmCause.ALARM_NOTICE_PATCH_EXPIRED, value = 50)) + + assertThat(systemNmShadow().allNotifications).isNotEmpty() + } + + @Test + fun `showNotification for bg-check hours and minutes without crashing`() { + createChannel() + + sut.showNotification(alarm(AlarmCause.ALARM_NOTICE_BG_CHECK, value = 125)) + + assertThat(systemNmShadow().allNotifications).isNotEmpty() + } + + @Test + fun `showNotification for bg-check under an hour formats minutes only without crashing`() { + createChannel() + + sut.showNotification(alarm(AlarmCause.ALARM_NOTICE_BG_CHECK, value = 45)) + + assertThat(systemNmShadow().allNotifications).isNotEmpty() + } + + @Test + fun `showNotification for a cause without a description still builds a notification`() { + // ALARM_NOTICE_ATTACH_PATCH_CHECK has a null notification description -> empty body, title only. + createChannel() + + sut.showNotification(alarm(AlarmCause.ALARM_NOTICE_ATTACH_PATCH_CHECK)) + + assertThat(systemNmShadow().allNotifications).isNotEmpty() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/CarelevoObserveReceiverTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/CarelevoObserveReceiverTest.kt new file mode 100644 index 000000000000..89342286da28 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/CarelevoObserveReceiverTest.kt @@ -0,0 +1,169 @@ +package app.aaps.pump.carelevo.common + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.disposables.Disposable +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowApplication + +/** + * Robolectric tests for [CarelevoObserveReceiver] — the Rx wrapper the plugin uses to observe + * Bluetooth adapter state changes. + * + * [CarelevoObserveReceiver] talks to the Android framework directly (`Context.registerReceiver` / + * `unregisterReceiver`, [BroadcastReceiver], [IntentFilter.matchAction]), so a plain JVM Mockito + * test could only observe that *some* mock method was called. Under [RobolectricTestRunner] with a + * REAL application [Context], registration is asserted against Robolectric's [ShadowApplication] + * receiver registry — i.e. against the same bookkeeping the OS would do. + * + * The registered [BroadcastReceiver] is pulled back out of the registry and its `onReceive` invoked + * directly, exactly as the system dispatcher would. That is deliberate rather than going through + * `context.sendBroadcast`: it keeps the test off the main-looper dispatch queue (no `idle()` + * ordering to reason about) AND it is the only way to reach the wrapper's two defensive branches — + * a null intent and a non-matching action — which a real broadcast could never deliver. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class CarelevoObserveReceiverTest { + + private val context: Context = RuntimeEnvironment.getApplication() + + private val action = "app.aaps.pump.carelevo.test.ACTION_STATE_CHANGED" + private val otherAction = "app.aaps.pump.carelevo.test.SOMETHING_ELSE" + + private val openSubscriptions = mutableListOf() + + private val shadowApplication: ShadowApplication + get() = Shadows.shadowOf(RuntimeEnvironment.getApplication()) + + /** Every receiver currently registered for [action] by the SUT. */ + private fun wrappersFor(action: String): List = + shadowApplication.registeredReceivers.filter { it.intentFilter.matchAction(action) } + + private fun observe(vararg actions: String, onNext: (Intent) -> Unit): Disposable { + val filter = IntentFilter() + actions.forEach { filter.addAction(it) } + return CarelevoObserveReceiver(context, filter) + .subscribe { intent -> onNext(intent) } + .also { openSubscriptions += it } + } + + @After + fun tearDown() { + // Never leak a registered receiver into the next test's registry. + openSubscriptions.filterNot { it.isDisposed }.forEach { it.dispose() } + } + + @Test + fun `subscribing registers a receiver for the supplied filter`() { + observe(action) {} + + val wrappers = wrappersFor(action) + assertThat(wrappers).hasSize(1) + assertThat(wrappers.single().broadcastReceiver).isNotNull() + assertThat(wrappers.single().intentFilter.matchAction(otherAction)).isFalse() + } + + @Test + fun `the receiver is never registered as exported`() { + // Only protected system broadcasts are consumed; nothing here may be targetable by other apps. + observe(action) {} + + assertThat(wrappersFor(action).single().flags and Context.RECEIVER_EXPORTED).isEqualTo(0) + } + + @Test + fun `a matching broadcast is emitted to the observer`() { + val received = mutableListOf() + observe(action) { received += it } + + val intent = Intent(action).putExtra("state", 12) + wrappersFor(action).single().broadcastReceiver.onReceive(context, intent) + + assertThat(received).hasSize(1) + assertThat(received.single().action).isEqualTo(action) + assertThat(received.single().getIntExtra("state", -1)).isEqualTo(12) + } + + @Test + fun `repeated broadcasts are all emitted on the same subscription`() { + val received = mutableListOf() + observe(action) { received += it } + val receiver = wrappersFor(action).single().broadcastReceiver + + receiver.onReceive(context, Intent(action).putExtra("state", 1)) + receiver.onReceive(context, Intent(action).putExtra("state", 2)) + receiver.onReceive(context, Intent(action).putExtra("state", 3)) + + assertThat(received.map { it.getIntExtra("state", -1) }).containsExactly(1, 2, 3).inOrder() + } + + @Test + fun `an intent whose action is outside the filter is not emitted`() { + val received = mutableListOf() + observe(action) { received += it } + + wrappersFor(action).single().broadcastReceiver.onReceive(context, Intent(otherAction)) + + assertThat(received).isEmpty() + } + + @Test + fun `a null intent is ignored`() { + val received = mutableListOf() + observe(action) { received += it } + + wrappersFor(action).single().broadcastReceiver.onReceive(context, null) + + assertThat(received).isEmpty() + } + + @Test + fun `every action of a multi-action filter is emitted`() { + val received = mutableListOf() + observe(action, otherAction) { received += it } + val receiver = wrappersFor(action).single().broadcastReceiver + + receiver.onReceive(context, Intent(action)) + receiver.onReceive(context, Intent(otherAction)) + + assertThat(received.map { it.action }).containsExactly(action, otherAction).inOrder() + } + + @Test + fun `disposing the subscription unregisters the receiver`() { + val disposable = observe(action) {} + assertThat(wrappersFor(action)).hasSize(1) + + disposable.dispose() + + assertThat(disposable.isDisposed).isTrue() + assertThat(wrappersFor(action)).isEmpty() + } + + @Test + fun `each subscription owns its own receiver and disposal only unregisters that one`() { + val firstReceived = mutableListOf() + val secondReceived = mutableListOf() + val first = observe(action) { firstReceived += it } + observe(action) { secondReceived += it } + assertThat(wrappersFor(action)).hasSize(2) + + first.dispose() + + val surviving = wrappersFor(action) + assertThat(surviving).hasSize(1) + surviving.single().broadcastReceiver.onReceive(context, Intent(action)) + assertThat(firstReceived).isEmpty() + assertThat(secondReceived).hasSize(1) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/CarelevoPatchMoreTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/CarelevoPatchMoreTest.kt new file mode 100644 index 000000000000..8ffa41ac0486 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/CarelevoPatchMoreTest.kt @@ -0,0 +1,604 @@ +package app.aaps.pump.carelevo.common + +import app.aaps.core.data.model.TE +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.profile.Profile +import app.aaps.core.interfaces.pump.ble.BleAdapter +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.rx.events.Event +import app.aaps.core.interfaces.rx.events.EventCustomActionsChanged +import app.aaps.core.interfaces.rx.events.EventPumpStatusChanged +import app.aaps.core.interfaces.rx.events.EventRefreshOverview +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.core.keys.DoubleKey +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.pump.carelevo.ble.CarelevoBleTransport +import app.aaps.pump.carelevo.ble.data.BleState +import app.aaps.pump.carelevo.ble.data.BondingState +import app.aaps.pump.carelevo.ble.data.DeviceModuleState +import app.aaps.pump.carelevo.ble.data.NotificationState +import app.aaps.pump.carelevo.ble.data.PeripheralConnectionState +import app.aaps.pump.carelevo.ble.data.ServiceDiscoverState +import app.aaps.pump.carelevo.common.keys.CarelevoIntPreferenceKey +import app.aaps.pump.carelevo.common.model.PatchState +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.model.userSetting.CarelevoUserSettingInfoDomainModel +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import app.aaps.pump.carelevo.domain.usecase.alarm.CarelevoAlarmInfoUseCase +import app.aaps.pump.carelevo.domain.usecase.infusion.CarelevoInfusionInfoMonitorUseCase +import app.aaps.pump.carelevo.domain.usecase.infusion.CarelevoPumpResumeUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchInfoMonitorUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchRptInfusionInfoProcessUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.model.CarelevoPatchRptInfusionInfoRequestModel +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoCreateUserSettingInfoUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoUserSettingInfoMonitorUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.model.CarelevoUserSettingInfoRequestModel +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.PublishSubject +import org.joda.time.DateTime +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +/** + * Second suite over the REAL [CarelevoPatch], covering what [CarelevoPatchTest] leaves open: + * the init/latch lifecycle, the `rxBus` fan-out of [CarelevoPatch.resolvePatchState], the + * profile-equality helper, the `applyInfusionInfoReport` enum round-trip, the + * [CarelevoPatch.handleAlarm] seam, the `discardTeardown` happy/single-flight paths, and the three monitor subscriptions + * ([CarelevoPatch.patchInfo] / [CarelevoPatch.infusionInfo] / [CarelevoPatch.userSettingInfo]) + * including the "no user-setting record → seed defaults from prefs" path. + * + * [CarelevoPatchTest] keeps its own (already green) coverage of the state-derivation matrix, the + * Bluetooth ON→OFF alarm edge and `flushPatchInformation`; nothing here duplicates it. + * + * Every Rx hop is pinned to [Schedulers.trampoline] so emissions are delivered synchronously on the + * test thread, exactly as in [CarelevoPatchTest]. + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoPatchMoreTest { + + @Mock lateinit var transport: CarelevoBleTransport + @Mock lateinit var aapsSchedulers: AapsSchedulers + @Mock lateinit var rxBus: RxBus + @Mock lateinit var sp: SP + @Mock lateinit var preferences: Preferences + @Mock lateinit var aapsLogger: AAPSLogger + @Mock lateinit var infusionInfoMonitorUseCase: CarelevoInfusionInfoMonitorUseCase + @Mock lateinit var patchInfoMonitorUseCase: CarelevoPatchInfoMonitorUseCase + @Mock lateinit var userSettingInfoMonitorUseCase: CarelevoUserSettingInfoMonitorUseCase + @Mock lateinit var patchRptInfusionInfoProcessUseCase: CarelevoPatchRptInfusionInfoProcessUseCase + @Mock lateinit var createUserSettingInfoUseCase: CarelevoCreateUserSettingInfoUseCase + @Mock lateinit var carelevoAlarmInfoUseCase: CarelevoAlarmInfoUseCase + @Mock lateinit var pumpResumeUseCase: CarelevoPumpResumeUseCase + @Mock lateinit var bleAdapter: BleAdapter + + private lateinit var sut: CarelevoPatch + + private fun bleState(enabled: Boolean): BleState = + BleState( + isEnabled = if (enabled) DeviceModuleState.DEVICE_STATE_ON else DeviceModuleState.DEVICE_STATE_OFF, + isBonded = BondingState.BOND_BONDED, + isServiceDiscovered = ServiceDiscoverState.DISCOVER_STATE_DISCOVERED, + isConnected = PeripheralConnectionState.CONN_STATE_CONNECTED, + isNotificationEnabled = NotificationState.NOTIFICATION_ENABLED + ) + + private fun patchInfo(): CarelevoPatchInfoDomainModel = + CarelevoPatchInfoDomainModel( + address = "aa:bb:cc:dd:ee:ff", + createdAt = DateTime.now().minusHours(1), + updatedAt = DateTime.now(), + manufactureNumber = "CARELEVO-TEST-001", + insulinRemain = 60.0, + bolusActionSeq = 1, + mode = 1 + ) + + /** Rebuilds the SUT — call after re-stubbing a monitor use case, the wiring happens in the ctor/initPatch. */ + private fun createPatch(): CarelevoPatch = + CarelevoPatch( + transport = transport, + aapsSchedulers = aapsSchedulers, + rxBus = rxBus, + sp = sp, + preferences = preferences, + aapsLogger = aapsLogger, + infusionInfoMonitorUseCase = infusionInfoMonitorUseCase, + patchInfoMonitorUseCase = patchInfoMonitorUseCase, + userSettingInfoMonitorUseCase = userSettingInfoMonitorUseCase, + patchRptInfusionInfoProcessUseCase = patchRptInfusionInfoProcessUseCase, + createUserSettingInfoUseCase = createUserSettingInfoUseCase, + carelevoAlarmInfoUseCase = carelevoAlarmInfoUseCase, + pumpResumeUseCase = pumpResumeUseCase + ) + + private fun profileWith(vararg values: Pair): Profile { + val profile = mock() + whenever(profile.getBasalValues()) + .thenReturn(values.map { Profile.ProfileValue(it.first, it.second) }.toTypedArray()) + return profile + } + + /** Everything the RxBus saw, in order. */ + private fun sentEvents(): List { + val captor = argumentCaptor() + verify(rxBus, atLeastOnce()).send(captor.capture()) + return captor.allValues + } + + @BeforeEach + fun setUp() { + whenever(aapsSchedulers.io).thenReturn(Schedulers.trampoline()) + whenever(aapsSchedulers.main).thenReturn(Schedulers.trampoline()) + whenever(patchInfoMonitorUseCase.execute()).thenReturn(Observable.just(ResponseResult.Success(patchInfo()))) + whenever(infusionInfoMonitorUseCase.execute()).thenReturn(Observable.never()) + whenever(userSettingInfoMonitorUseCase.execute()).thenReturn(Observable.never()) + whenever(carelevoAlarmInfoUseCase.upsertAlarm(any())).thenReturn(Completable.complete()) + whenever(patchRptInfusionInfoProcessUseCase.execute(any())) + .thenReturn(Single.just(ResponseResult.Success(null))) + whenever(createUserSettingInfoUseCase.execute(any())) + .thenReturn(Single.just(ResponseResult.Success(null))) + whenever(transport.adapter).thenReturn(bleAdapter) + + sut = createPatch() + } + + // --------------------------------------------------------------------------------------------- + // site placement (wizard hand-off to the CANNULA_CHANGE therapy event) + // --------------------------------------------------------------------------------------------- + + @Test + fun `site placement defaults to NONE when the wizard step is skipped`() { + assertThat(sut.sitePlacementLocation).isEqualTo(TE.Location.NONE) + assertThat(sut.sitePlacementArrow).isEqualTo(TE.Arrow.NONE) + } + + @Test + fun `setSitePlacement stores the location and arrow chosen in the wizard`() { + sut.setSitePlacement(TE.Location.SIDE_RIGHT_UPPER_ARM, TE.Arrow.UP) + + assertThat(sut.sitePlacementLocation).isEqualTo(TE.Location.SIDE_RIGHT_UPPER_ARM) + assertThat(sut.sitePlacementArrow).isEqualTo(TE.Arrow.UP) + + // Last write wins — the flow re-sets it on every step change. + sut.setSitePlacement(TE.Location.NONE, TE.Arrow.NONE) + assertThat(sut.sitePlacementLocation).isEqualTo(TE.Location.NONE) + assertThat(sut.sitePlacementArrow).isEqualTo(TE.Arrow.NONE) + } + + // --------------------------------------------------------------------------------------------- + // init lifecycle + // --------------------------------------------------------------------------------------------- + + @Test + fun `initPatch latches isWorking and subscribes each monitor exactly once`() { + assertThat(sut.isWorking).isFalse() + + sut.initPatch() + sut.initPatch() + + assertThat(sut.isWorking).isTrue() + verify(patchInfoMonitorUseCase, times(1)).execute() + verify(infusionInfoMonitorUseCase, times(1)).execute() + verify(userSettingInfoMonitorUseCase, times(1)).execute() + } + + @Test + fun `initPatchAndAwait defers initPatch until it is subscribed`() { + val completable = sut.initPatchAndAwait() + + // Completable.defer: nothing runs at assembly time. + assertThat(sut.isWorking).isFalse() + verify(patchInfoMonitorUseCase, never()).execute() + + completable.subscribe({}, {}) + + assertThat(sut.isWorking).isTrue() + verify(patchInfoMonitorUseCase, times(1)).execute() + } + + @Test + fun `initPatchOnce shares one in-flight Completable across duplicate callers`() { + val first = sut.initPatchOnce() + val second = sut.initPatchOnce() + + assertThat(second).isSameInstanceAs(first) + } + + // --------------------------------------------------------------------------------------------- + // observeChangeState → patchState publication + rxBus fan-out + // --------------------------------------------------------------------------------------------- + + @Test + fun `an activated patch with bluetooth on publishes ConnectedBooted and the CONNECTED fan-out`() { + sut.initPatch() + sut.onBluetoothStateChanged(bleState(enabled = true)) + + assertThat(sut.patchState.value?.get()).isEqualTo(PatchState.ConnectedBooted) + + val events = sentEvents() + assertThat(events.filterIsInstance().map { it.status }) + .containsExactly(EventPumpStatusChanged.Status.CONNECTED) + assertThat(events.filterIsInstance().map { it.from }) + .containsExactly("Carelevo connection state") + assertThat(events.filterIsInstance()).hasSize(1) + } + + @Test + fun `no patch record publishes NotConnectedNotBooting and the DISCONNECTED fan-out`() { + whenever(patchInfoMonitorUseCase.execute()) + .thenReturn(Observable.just(ResponseResult.Success(null))) + sut = createPatch() + + sut.initPatch() + sut.onBluetoothStateChanged(bleState(enabled = true)) + + assertThat(sut.patchState.value?.get()).isEqualTo(PatchState.NotConnectedNotBooting) + assertThat(sut.getPatchInfoAddress()).isNull() + + val events = sentEvents() + assertThat(events.filterIsInstance().map { it.status }) + .containsExactly(EventPumpStatusChanged.Status.DISCONNECTED) + assertThat(events.filterIsInstance()).hasSize(1) + assertThat(events.filterIsInstance()).hasSize(1) + } + + @Test + fun `NotConnectedBooted is published silently without any rxBus fan-out`() { + // Bluetooth off with a valid patch is a UI-only state: overview must not be told the pump + // disconnected (the patch keeps delivering basal on its own). + sut.initPatch() + sut.onBluetoothStateChanged(bleState(enabled = false)) + + assertThat(sut.patchState.value?.get()).isEqualTo(PatchState.NotConnectedBooted) + verify(rxBus, never()).send(any()) + } + + // --------------------------------------------------------------------------------------------- + // checkIsSameProfile + // --------------------------------------------------------------------------------------------- + + @Test + fun `checkIsSameProfile is false before any profile has been set`() { + assertThat(sut.checkIsSameProfile(profileWith(0 to 1.0))).isFalse() + } + + @Test + fun `checkIsSameProfile is false for a null candidate`() { + sut.setProfile(profileWith(0 to 1.0)) + + assertThat(sut.checkIsSameProfile(null)).isFalse() + } + + @Test + fun `checkIsSameProfile is false when the segment count differs`() { + sut.setProfile(profileWith(0 to 1.0)) + + assertThat(sut.checkIsSameProfile(profileWith(0 to 1.0, 3600 to 1.0))).isFalse() + } + + @Test + fun `checkIsSameProfile is false when a segment start time differs`() { + sut.setProfile(profileWith(0 to 1.0, 3600 to 2.0)) + + assertThat(sut.checkIsSameProfile(profileWith(0 to 1.0, 7200 to 2.0))).isFalse() + } + + @Test + fun `checkIsSameProfile ignores sub-minute start time differences`() { + // Comparison is minute-resolution: 0s and 30s are the same pump segment boundary. + sut.setProfile(profileWith(0 to 1.0)) + + assertThat(sut.checkIsSameProfile(profileWith(30 to 1.0))).isTrue() + } + + @Test + fun `checkIsSameProfile is true for an identical profile`() { + sut.setProfile(profileWith(0 to 0.75, 3600 to 1.25)) + + assertThat(sut.checkIsSameProfile(profileWith(0 to 0.75, 3600 to 1.25))).isTrue() + } + + @Test + fun `checkIsSameProfile tolerates a sub-epsilon basal rate difference`() { + sut.setProfile(profileWith(0 to 1.0)) + + assertThat(sut.checkIsSameProfile(profileWith(0 to 1.0005))).isTrue() + } + + @Test + fun `checkIsSameProfile is false for a materially different basal rate`() { + sut.setProfile(profileWith(0 to 1.0)) + + assertThat(sut.checkIsSameProfile(profileWith(0 to 1.5))).isFalse() + } + + @Test + fun `checkIsSameProfile is false when a segment is zeroed out`() { + // Guards nearlyEqual's zero branch: 0 vs 0.5 must never collapse to "same". + sut.setProfile(profileWith(0 to 0.5)) + + assertThat(sut.checkIsSameProfile(profileWith(0 to 0.0))).isFalse() + } + + // --------------------------------------------------------------------------------------------- + // applyInfusionInfoReport — raw 0x91 bytes normalized through the enum round-trip + // --------------------------------------------------------------------------------------------- + + @Test + fun `applyInfusionInfoReport persists known raw codes through the enum round-trip`() { + sut.applyInfusionInfoReport( + runningMinutes = 42, + remains = 137.5, + infusedTotalBasalAmount = 12.25, + infusedTotalBolusAmount = 3.5, + pumpStateRaw = 2, // RUNNING + modeRaw = 5 // EXTEND_BOLUS + ) + + val captor = argumentCaptor() + verify(patchRptInfusionInfoProcessUseCase).execute(captor.capture()) + val request = captor.firstValue + assertThat(request.runningMinute).isEqualTo(42) + assertThat(request.remains).isEqualTo(137.5) + assertThat(request.infusedTotalBasalAmount).isEqualTo(12.25) + assertThat(request.infusedTotalBolusAmount).isEqualTo(3.5) + assertThat(request.pumpState).isEqualTo(2) + assertThat(request.mode).isEqualTo(5) + // Not carried by the 0x91 report — the process use case does not persist them. + assertThat(request.currentInfusedProgramVolume).isEqualTo(0.0) + assertThat(request.realInfusedTime).isEqualTo(0) + } + + @Test + fun `applyInfusionInfoReport maps unknown raw codes onto the ERROR codes`() { + sut.applyInfusionInfoReport( + runningMinutes = 1, + remains = 1.0, + infusedTotalBasalAmount = 0.0, + infusedTotalBolusAmount = 0.0, + pumpStateRaw = 99, + modeRaw = 99 + ) + + val captor = argumentCaptor() + verify(patchRptInfusionInfoProcessUseCase).execute(captor.capture()) + assertThat(captor.firstValue.pumpState).isEqualTo(3) // PumpStateResult.ERROR + assertThat(captor.firstValue.mode).isEqualTo(-1) // InfusionModeResult.ERROR + } + + @Test + fun `applyInfusionInfoReport round-trips every basal-to-bolus mode code unchanged`() { + listOf(1, 2, 3, 4, 5).forEach { sut.applyInfusionInfoReport(0, 0.0, 0.0, 0.0, 0, it) } + + val captor = argumentCaptor() + verify(patchRptInfusionInfoProcessUseCase, times(5)).execute(captor.capture()) + assertThat(captor.allValues.map { it.mode }).containsExactly(1, 2, 3, 4, 5).inOrder() + // pumpStateRaw 0 → READY → 0 on every one of them. + assertThat(captor.allValues.map { it.pumpState }.distinct()).containsExactly(0) + } + + // --------------------------------------------------------------------------------------------- + // handleAlarm — the public unsolicitedEvents bridge seam + // --------------------------------------------------------------------------------------------- + + @Test + fun `handleAlarm raises an unacknowledged alarm carrying the cause value and type`() { + sut.handleAlarm("warning", value = 5, cause = AlarmCause.ALARM_WARNING_LOW_INSULIN) + + val captor = argumentCaptor() + verify(carelevoAlarmInfoUseCase).upsertAlarm(captor.capture()) + val info = captor.firstValue + assertThat(info.cause).isEqualTo(AlarmCause.ALARM_WARNING_LOW_INSULIN) + assertThat(info.alarmType).isEqualTo(AlarmCause.ALARM_WARNING_LOW_INSULIN.alarmType) + assertThat(info.value).isEqualTo(5) + assertThat(info.isAcknowledged).isFalse() + assertThat(info.alarmId).isNotEmpty() + assertThat(info.createdAt).isNotEmpty() + assertThat(info.updatedAt).isNotEmpty() + } + + @Test + fun `handleAlarm swallows an upsert failure instead of tearing down the caller`() { + whenever(carelevoAlarmInfoUseCase.upsertAlarm(any())).thenReturn(Completable.error(RuntimeException("db down"))) + + // The bridge runs on the BLE notification path — an alarm write failure must not escape. + sut.handleAlarm("alert", value = null, cause = AlarmCause.ALARM_ALERT_BLE_NOT_CONNECTED) + + val captor = argumentCaptor() + verify(carelevoAlarmInfoUseCase).upsertAlarm(captor.capture()) + assertThat(captor.firstValue.value).isNull() + } + + // --------------------------------------------------------------------------------------------- + // reconcileAutoResumed — the auto-resume guard + // --------------------------------------------------------------------------------------------- + + @Test + fun `reconcileAutoResumed clears the stopped state when the pump is stopped`() { + whenever(patchInfoMonitorUseCase.execute()) + .thenReturn(Observable.just(ResponseResult.Success(patchInfo().copy(isStopped = true, stopMinutes = 30)))) + whenever(pumpResumeUseCase.persistResumed()).thenReturn(true) + sut = createPatch() + sut.initPatch() + + sut.reconcileAutoResumed() + + verify(pumpResumeUseCase).persistResumed() + } + + @Test + fun `reconcileAutoResumed is a no-op while the pump is running`() { + // The default patchInfo() has isStopped = null (running); a basal-restart report (0x88) that fires + // during normal delivery, or races a stop, must NOT clear a legitimate suspend. + sut.initPatch() + + sut.reconcileAutoResumed() + + verify(pumpResumeUseCase, never()).persistResumed() + } + + // --------------------------------------------------------------------------------------------- + // releasePatch / discardTeardown + // --------------------------------------------------------------------------------------------- + + @Test + fun `releasePatch clears both the cached patch and infusion info`() { + whenever(infusionInfoMonitorUseCase.execute()) + .thenReturn(Observable.just(ResponseResult.Success(CarelevoInfusionInfoDomainModel()))) + sut = createPatch() + sut.initPatch() + assertThat(sut.infusionInfo.value?.isPresent).isTrue() + + sut.releasePatch() + + assertThat(sut.getPatchInfoAddress()).isNull() + assertThat(sut.patchInfo.value?.isPresent).isFalse() + assertThat(sut.infusionInfo.value?.isPresent).isFalse() + } + + @Test + fun `discardTeardown removes the OS bond for the uppercased patch address`() { + sut.initPatch() + sut.onBluetoothStateChanged(bleState(enabled = true)) + + sut.discardTeardown() + + verify(bleAdapter).removeBond("AA:BB:CC:DD:EE:FF") + assertThat(sut.getPatchInfoAddress()).isNull() + assertThat(sut.resolvePatchState()).isEqualTo(PatchState.NotConnectedNotBooting) + } + + @Test + fun `discardTeardown without a patch record skips the unbond`() { + sut.discardTeardown() + + verify(bleAdapter, never()).removeBond(any()) + } + + @Test + fun `discardTeardown is single-flight against a re-entrant caller`() { + sut.initPatch() + sut.onBluetoothStateChanged(bleState(enabled = true)) + var reentered = false + doAnswer { + if (!reentered) { + reentered = true + // A queued CmdDiscard racing the ViewModel force-discard fallback: must be skipped. + sut.discardTeardown() + } + null + }.whenever(bleAdapter).removeBond(any()) + + sut.discardTeardown() + + assertThat(reentered).isTrue() + verify(bleAdapter, times(1)).removeBond(any()) + } + + @Test + fun `discardTeardown releases the single-flight latch for the next patch`() { + // Drive patchInfo by hand so a SECOND patch can be activated after the first is discarded. + val patchRecords = PublishSubject.create>() + whenever(patchInfoMonitorUseCase.execute()).thenReturn(patchRecords) + sut = createPatch() + sut.initPatch() + sut.onBluetoothStateChanged(bleState(enabled = true)) + + patchRecords.onNext(ResponseResult.Success(patchInfo())) + sut.discardTeardown() + // A later activation must not be blocked by a latch left set by the previous teardown. + patchRecords.onNext(ResponseResult.Success(patchInfo())) + sut.discardTeardown() + + verify(bleAdapter, times(2)).removeBond("AA:BB:CC:DD:EE:FF") + assertThat(sut.getPatchInfoAddress()).isNull() + } + + // --------------------------------------------------------------------------------------------- + // monitor subscriptions + // --------------------------------------------------------------------------------------------- + + @Test + fun `a successful infusion info emission is cached`() { + val model = CarelevoInfusionInfoDomainModel() + whenever(infusionInfoMonitorUseCase.execute()).thenReturn(Observable.just(ResponseResult.Success(model))) + sut = createPatch() + + sut.initPatch() + + assertThat(sut.infusionInfo.value?.get()).isEqualTo(model) + } + + @Test + fun `monitor errors and failures leave the cached info empty`() { + whenever(infusionInfoMonitorUseCase.execute()) + .thenReturn(Observable.just(ResponseResult.Error(RuntimeException("boom")), ResponseResult.Failure("nope"))) + whenever(patchInfoMonitorUseCase.execute()) + .thenReturn(Observable.just(ResponseResult.Error(RuntimeException("boom")), ResponseResult.Failure("nope"))) + whenever(userSettingInfoMonitorUseCase.execute()) + .thenReturn(Observable.just(ResponseResult.Error(RuntimeException("boom")), ResponseResult.Failure("nope"))) + sut = createPatch() + + sut.initPatch() + + assertThat(sut.infusionInfo.value).isNull() + assertThat(sut.patchInfo.value).isNull() + assertThat(sut.userSettingInfo.value).isNull() + // An errored user-setting read must NOT be mistaken for "no record" and seed defaults. + verify(createUserSettingInfoUseCase, never()).execute(any()) + } + + @Test + fun `an existing user setting record is cached and no defaults are seeded`() { + val model = CarelevoUserSettingInfoDomainModel(lowInsulinNoticeAmount = 8, maxBasalSpeed = 15.0, maxBolusDose = 4.0) + whenever(userSettingInfoMonitorUseCase.execute()).thenReturn(Observable.just(ResponseResult.Success(model))) + sut = createPatch() + + sut.initPatch() + + assertThat(sut.userSettingInfo.value?.get()).isEqualTo(model) + verify(createUserSettingInfoUseCase, never()).execute(any()) + } + + @Test + fun `a missing user setting record seeds the defaults from the preferences`() { + whenever(userSettingInfoMonitorUseCase.execute()) + .thenReturn(Observable.just(ResponseResult.Success(null))) + whenever(sp.getInt(CarelevoIntPreferenceKey.CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS.key, 30)).thenReturn(12) + whenever(preferences.get(DoubleKey.SafetyMaxBolus)).thenReturn(7.5) + sut = createPatch() + + sut.initPatch() + + val captor = argumentCaptor() + verify(createUserSettingInfoUseCase).execute(captor.capture()) + assertThat(captor.firstValue.lowInsulinNoticeAmount).isEqualTo(12) + assertThat(captor.firstValue.maxBasalSpeed).isEqualTo(15.0) + assertThat(captor.firstValue.maxBolusDose).isEqualTo(7.5) + assertThat(captor.firstValue.patchState).isNull() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/CarelevoPatchTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/CarelevoPatchTest.kt new file mode 100644 index 000000000000..b560c551b0cf --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/CarelevoPatchTest.kt @@ -0,0 +1,206 @@ +package app.aaps.pump.carelevo.common + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.pump.carelevo.ble.CarelevoBleTransport +import app.aaps.pump.carelevo.ble.data.BleState +import app.aaps.pump.carelevo.ble.data.BondingState +import app.aaps.pump.carelevo.ble.data.DeviceModuleState +import app.aaps.pump.carelevo.ble.data.NotificationState +import app.aaps.pump.carelevo.ble.data.PeripheralConnectionState +import app.aaps.pump.carelevo.ble.data.ServiceDiscoverState +import app.aaps.pump.carelevo.common.model.PatchState +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.usecase.alarm.CarelevoAlarmInfoUseCase +import app.aaps.pump.carelevo.domain.usecase.infusion.CarelevoInfusionInfoMonitorUseCase +import app.aaps.pump.carelevo.domain.usecase.infusion.CarelevoPumpResumeUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchInfoMonitorUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchRptInfusionInfoProcessUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoCreateUserSettingInfoUseCase +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoUserSettingInfoMonitorUseCase +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.joda.time.DateTime +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +/** + * Tests the REAL [CarelevoPatch] (everywhere else it is mocked away): patch-state derivation, + * Bluetooth edge-triggered alarm raising, and the flush/teardown bookkeeping every coordinator + * depends on. + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoPatchTest { + + @Mock lateinit var transport: CarelevoBleTransport + @Mock lateinit var aapsSchedulers: AapsSchedulers + @Mock lateinit var rxBus: RxBus + @Mock lateinit var sp: SP + @Mock lateinit var preferences: Preferences + @Mock lateinit var aapsLogger: AAPSLogger + @Mock lateinit var infusionInfoMonitorUseCase: CarelevoInfusionInfoMonitorUseCase + @Mock lateinit var patchInfoMonitorUseCase: CarelevoPatchInfoMonitorUseCase + @Mock lateinit var userSettingInfoMonitorUseCase: CarelevoUserSettingInfoMonitorUseCase + @Mock lateinit var patchRptInfusionInfoProcessUseCase: CarelevoPatchRptInfusionInfoProcessUseCase + @Mock lateinit var createUserSettingInfoUseCase: CarelevoCreateUserSettingInfoUseCase + @Mock lateinit var carelevoAlarmInfoUseCase: CarelevoAlarmInfoUseCase + @Mock lateinit var pumpResumeUseCase: CarelevoPumpResumeUseCase + + private lateinit var sut: CarelevoPatch + + private fun bleState(enabled: Boolean): BleState = + BleState( + isEnabled = if (enabled) DeviceModuleState.DEVICE_STATE_ON else DeviceModuleState.DEVICE_STATE_OFF, + isBonded = BondingState.BOND_BONDED, + isServiceDiscovered = ServiceDiscoverState.DISCOVER_STATE_DISCOVERED, + isConnected = PeripheralConnectionState.CONN_STATE_CONNECTED, + isNotificationEnabled = NotificationState.NOTIFICATION_ENABLED + ) + + private fun patchInfo(): CarelevoPatchInfoDomainModel = + CarelevoPatchInfoDomainModel( + address = "aa:bb:cc:dd:ee:ff", + createdAt = DateTime.now().minusHours(1), + updatedAt = DateTime.now(), + manufactureNumber = "CARELEVO-TEST-001", + insulinRemain = 60.0, + bolusActionSeq = 1, + mode = 1 + ) + + @BeforeEach + fun setUp() { + whenever(aapsSchedulers.io).thenReturn(Schedulers.trampoline()) + whenever(aapsSchedulers.main).thenReturn(Schedulers.trampoline()) + whenever(patchInfoMonitorUseCase.execute()).thenReturn(Observable.just(ResponseResult.Success(patchInfo()))) + whenever(infusionInfoMonitorUseCase.execute()).thenReturn(Observable.never()) + whenever(userSettingInfoMonitorUseCase.execute()).thenReturn(Observable.never()) + whenever(carelevoAlarmInfoUseCase.upsertAlarm(any())).thenReturn(Completable.complete()) + + sut = CarelevoPatch( + transport = transport, + aapsSchedulers = aapsSchedulers, + rxBus = rxBus, + sp = sp, + preferences = preferences, + aapsLogger = aapsLogger, + infusionInfoMonitorUseCase = infusionInfoMonitorUseCase, + patchInfoMonitorUseCase = patchInfoMonitorUseCase, + userSettingInfoMonitorUseCase = userSettingInfoMonitorUseCase, + patchRptInfusionInfoProcessUseCase = patchRptInfusionInfoProcessUseCase, + createUserSettingInfoUseCase = createUserSettingInfoUseCase, + carelevoAlarmInfoUseCase = carelevoAlarmInfoUseCase, + pumpResumeUseCase = pumpResumeUseCase + ) + } + + @Test + fun `resolvePatchState is NotConnectedNotBooting without a patch record`() { + sut.onBluetoothStateChanged(bleState(enabled = true)) + + assertThat(sut.resolvePatchState()).isEqualTo(PatchState.NotConnectedNotBooting) + } + + @Test + fun `resolvePatchState is ConnectedBooted with a patch record and available bluetooth`() { + sut.initPatch() + sut.onBluetoothStateChanged(bleState(enabled = true)) + + assertThat(sut.resolvePatchState()).isEqualTo(PatchState.ConnectedBooted) + assertThat(sut.getPatchInfoAddress()).isEqualTo("aa:bb:cc:dd:ee:ff") + } + + @Test + fun `resolvePatchState is NotConnectedBooted with a patch record but bluetooth off`() { + sut.initPatch() + sut.onBluetoothStateChanged(bleState(enabled = false)) + + assertThat(sut.resolvePatchState()).isEqualTo(PatchState.NotConnectedBooted) + } + + @Test + fun `flushPatchInformation drops the patch record and the derived state`() { + sut.initPatch() + sut.onBluetoothStateChanged(bleState(enabled = true)) + assertThat(sut.resolvePatchState()).isEqualTo(PatchState.ConnectedBooted) + + sut.flushPatchInformation() + + assertThat(sut.getPatchInfoAddress()).isNull() + assertThat(sut.resolvePatchState()).isEqualTo(PatchState.NotConnectedNotBooting) + } + + @Test + fun `bluetooth-off alarm fires only on the ON to OFF edge`() { + // Initial OFF observation (no previous state): no alarm — this is a startup seed, not an edge. + sut.onBluetoothStateChanged(bleState(enabled = false)) + verify(carelevoAlarmInfoUseCase, never()).upsertAlarm(any()) + + // ON → OFF: exactly one alarm. + sut.onBluetoothStateChanged(bleState(enabled = true)) + sut.onBluetoothStateChanged(bleState(enabled = false)) + val captor = argumentCaptor() + verify(carelevoAlarmInfoUseCase, times(1)).upsertAlarm(captor.capture()) + assertThat(captor.firstValue.cause).isEqualTo(AlarmCause.ALARM_ALERT_BLUETOOTH_OFF) + assertThat(captor.firstValue.isAcknowledged).isFalse() + + // Repeated OFF broadcasts: no alarm spam. + sut.onBluetoothStateChanged(bleState(enabled = false)) + verify(carelevoAlarmInfoUseCase, times(1)).upsertAlarm(any()) + } + + @Test + fun `alarm ids are unique across alarms raised in rapid succession`() { + sut.onBluetoothStateChanged(bleState(enabled = true)) + sut.onBluetoothStateChanged(bleState(enabled = false)) + sut.onBluetoothStateChanged(bleState(enabled = true)) + sut.onBluetoothStateChanged(bleState(enabled = false)) + + val captor = argumentCaptor() + verify(carelevoAlarmInfoUseCase, times(2)).upsertAlarm(captor.capture()) + assertThat(captor.firstValue.alarmId).isNotEqualTo(captor.secondValue.alarmId) + } + + @Test + fun `isBluetoothEnabled reflects the last adapter state`() { + assertThat(sut.isBluetoothEnabled()).isFalse() + + sut.onBluetoothStateChanged(bleState(enabled = true)) + assertThat(sut.isBluetoothEnabled()).isTrue() + + sut.onBluetoothStateChanged(bleState(enabled = false)) + assertThat(sut.isBluetoothEnabled()).isFalse() + } + + @Test + fun `discardTeardown clears patch state even when the unbond throws`() { + sut.initPatch() + sut.onBluetoothStateChanged(bleState(enabled = true)) + whenever(transport.adapter).thenThrow(RuntimeException("no adapter")) + + sut.discardTeardown() + + assertThat(sut.getPatchInfoAddress()).isNull() + assertThat(sut.resolvePatchState()).isEqualTo(PatchState.NotConnectedNotBooting) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/CarelevoReceiverDisposableTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/CarelevoReceiverDisposableTest.kt new file mode 100644 index 000000000000..fdb4ae26a50c --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/CarelevoReceiverDisposableTest.kt @@ -0,0 +1,84 @@ +package app.aaps.pump.carelevo.common + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowApplication + +/** + * Robolectric tests for [CarelevoReceiverDisposable] — the teardown half of + * [CarelevoObserveReceiver], which prevents the classic leaked-receiver crash when the plugin + * clears its subscription in `onStop()`. + * + * `Context.unregisterReceiver` only has observable behaviour against a real framework registry, so + * this runs under [RobolectricTestRunner] with a REAL application [Context] and asserts against + * Robolectric's [ShadowApplication] receiver registry — a mocked Context would only prove that a + * method was called, not that the receiver actually stopped being registered. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class CarelevoReceiverDisposableTest { + + private val context: Context = RuntimeEnvironment.getApplication() + + private val action = "app.aaps.pump.carelevo.test.DISPOSABLE_ACTION" + + private val shadowApplication: ShadowApplication + get() = Shadows.shadowOf(RuntimeEnvironment.getApplication()) + + private fun isRegistered(receiver: BroadcastReceiver): Boolean = + shadowApplication.registeredReceivers.any { it.broadcastReceiver === receiver } + + private fun registerReceiver(): BroadcastReceiver { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) {} + } + context.registerReceiver(receiver, IntentFilter(action), Context.RECEIVER_NOT_EXPORTED) + return receiver + } + + @Test + fun `a fresh disposable is not disposed and leaves the receiver registered`() { + val receiver = registerReceiver() + + val sut = CarelevoReceiverDisposable(context, receiver) + + assertThat(sut.isDisposed).isFalse() + assertThat(isRegistered(receiver)).isTrue() + + sut.dispose() + } + + @Test + fun `dispose unregisters the receiver and flips isDisposed`() { + val receiver = registerReceiver() + val sut = CarelevoReceiverDisposable(context, receiver) + + sut.dispose() + + assertThat(sut.isDisposed).isTrue() + assertThat(isRegistered(receiver)).isFalse() + } + + @Test + fun `dispose only unregisters its own receiver`() { + val mine = registerReceiver() + val someoneElse = registerReceiver() + val sut = CarelevoReceiverDisposable(context, mine) + + sut.dispose() + + assertThat(isRegistered(mine)).isFalse() + assertThat(isRegistered(someoneElse)).isTrue() + + context.unregisterReceiver(someoneElse) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/keys/CarelevoBooleanPreferenceKeyTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/keys/CarelevoBooleanPreferenceKeyTest.kt new file mode 100644 index 000000000000..d8aafe6fff9b --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/keys/CarelevoBooleanPreferenceKeyTest.kt @@ -0,0 +1,64 @@ +package app.aaps.pump.carelevo.common.keys + +import app.aaps.core.keys.interfaces.BooleanPreferenceKey +import app.aaps.pump.carelevo.R +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +internal class CarelevoBooleanPreferenceKeyTest { + + @Test + fun `has exactly the two declared keys`() { + assertThat(CarelevoBooleanPreferenceKey.entries).containsExactly( + CarelevoBooleanPreferenceKey.CARELEVO_BUZZER_REMINDER, + CarelevoBooleanPreferenceKey.CARELEVO_CAGE_DEFAULT_APPLIED + ).inOrder() + } + + @Test + fun `implements the BooleanPreferenceKey contract`() { + CarelevoBooleanPreferenceKey.entries.forEach { + assertThat(it).isInstanceOf(BooleanPreferenceKey::class.java) + } + } + + @Test + fun `buzzer reminder metadata`() { + val k = CarelevoBooleanPreferenceKey.CARELEVO_BUZZER_REMINDER + assertThat(k.key).isEqualTo("CARELEVO_BUZZER_REMINDER") + assertThat(k.defaultValue).isFalse() + assertThat(k.titleResId).isEqualTo(R.string.carelevo_patch_buzzer_alarm_title) + } + + @Test + fun `cage default applied metadata`() { + val k = CarelevoBooleanPreferenceKey.CARELEVO_CAGE_DEFAULT_APPLIED + assertThat(k.key).isEqualTo("carelevo_cage_default_applied") + assertThat(k.defaultValue).isFalse() + assertThat(k.titleResId).isEqualTo(0) + } + + @Test + fun `keys are unique and non-blank`() { + val keys = CarelevoBooleanPreferenceKey.entries.map { it.key } + assertThat(keys.toSet()).hasSize(keys.size) + assertThat(keys).containsNoDuplicates() + keys.forEach { assertThat(it).isNotEmpty() } + } + + @Test + fun `default flags match the enum defaults`() { + CarelevoBooleanPreferenceKey.entries.forEach { k -> + assertThat(k.calculatedDefaultValue).isFalse() + assertThat(k.engineeringModeOnly).isFalse() + assertThat(k.defaultedBySM).isFalse() + assertThat(k.showInApsMode).isTrue() + assertThat(k.showInNsClientMode).isTrue() + assertThat(k.showInPumpControlMode).isTrue() + assertThat(k.dependency).isNull() + assertThat(k.negativeDependency).isNull() + assertThat(k.hideParentScreenIfHidden).isFalse() + assertThat(k.exportable).isTrue() + } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/keys/CarelevoIntPreferenceKeyTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/keys/CarelevoIntPreferenceKeyTest.kt new file mode 100644 index 000000000000..92a39b7900ff --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/common/keys/CarelevoIntPreferenceKeyTest.kt @@ -0,0 +1,82 @@ +package app.aaps.pump.carelevo.common.keys + +import app.aaps.core.keys.PreferenceType +import app.aaps.core.keys.interfaces.IntPreferenceKey +import app.aaps.pump.carelevo.R +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +internal class CarelevoIntPreferenceKeyTest { + + @Test + fun `has exactly the two declared keys`() { + assertThat(CarelevoIntPreferenceKey.entries).containsExactly( + CarelevoIntPreferenceKey.CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS, + CarelevoIntPreferenceKey.CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS + ).inOrder() + } + + @Test + fun `implements the IntPreferenceKey contract`() { + CarelevoIntPreferenceKey.entries.forEach { + assertThat(it).isInstanceOf(IntPreferenceKey::class.java) + } + } + + @Test + fun `patch expiration reminder metadata`() { + val k = CarelevoIntPreferenceKey.CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS + assertThat(k.key).isEqualTo("CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS") + assertThat(k.defaultValue).isEqualTo(116) + assertThat(k.titleResId).isEqualTo(R.string.carelevo_patch_expiration_reminders_title_value) + assertThat(k.preferenceType).isEqualTo(PreferenceType.LIST) + } + + @Test + fun `low insulin reminder metadata`() { + val k = CarelevoIntPreferenceKey.CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS + assertThat(k.key).isEqualTo("CARELEVO_LOW_INSULIN_EXPIRATION_REMINDER_HOURS") + assertThat(k.defaultValue).isEqualTo(30) + assertThat(k.titleResId).isEqualTo(R.string.carelevo_low_reservoir_reminders_title_value) + assertThat(k.preferenceType).isEqualTo(PreferenceType.LIST) + } + + @Test + fun `keys are unique and non-blank`() { + val keys = CarelevoIntPreferenceKey.entries.map { it.key } + assertThat(keys.toSet()).hasSize(keys.size) + assertThat(keys).containsNoDuplicates() + keys.forEach { assertThat(it).isNotEmpty() } + } + + @Test + fun `min and max fall back to the Int range defaults`() { + CarelevoIntPreferenceKey.entries.forEach { k -> + assertThat(k.min).isEqualTo(Int.MIN_VALUE) + assertThat(k.max).isEqualTo(Int.MAX_VALUE) + } + } + + @Test + fun `entries map defaults to empty`() { + CarelevoIntPreferenceKey.entries.forEach { k -> + assertThat(k.entries).isEmpty() + } + } + + @Test + fun `default flags match the enum defaults`() { + CarelevoIntPreferenceKey.entries.forEach { k -> + assertThat(k.calculatedDefaultValue).isFalse() + assertThat(k.engineeringModeOnly).isFalse() + assertThat(k.defaultedBySM).isFalse() + assertThat(k.showInApsMode).isTrue() + assertThat(k.showInNsClientMode).isTrue() + assertThat(k.showInPumpControlMode).isTrue() + assertThat(k.dependency).isNull() + assertThat(k.negativeDependency).isNull() + assertThat(k.hideParentScreenIfHidden).isFalse() + assertThat(k.exportable).isTrue() + } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/CarelevoComposeContentTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/CarelevoComposeContentTest.kt new file mode 100644 index 000000000000..41d6f665a0a0 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/CarelevoComposeContentTest.kt @@ -0,0 +1,444 @@ +package app.aaps.pump.carelevo.compose + +import android.content.Context +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.isSelectable +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +// Aliased: the simple name collides with Robolectric's @Config annotation used below. +import app.aaps.core.interfaces.configuration.Config as AapsConfig +import app.aaps.core.interfaces.configuration.ExternalOptions +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.protection.ProtectionCheck +import app.aaps.core.interfaces.pump.BlePreCheck +import app.aaps.core.interfaces.ui.IconsProvider +import app.aaps.core.ui.compose.ComposablePluginContent +import app.aaps.core.ui.compose.StatusLevel +import app.aaps.core.ui.compose.pump.PumpAction +import app.aaps.core.ui.compose.pump.PumpInfoRow +import app.aaps.core.ui.compose.pump.PumpOverviewUiState +import app.aaps.core.ui.compose.pump.StatusBanner +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.common.CarelevoAlarmNotifier +import app.aaps.pump.carelevo.common.MutableEventFlow +import app.aaps.pump.carelevo.common.model.Event +import app.aaps.pump.carelevo.common.model.State +import app.aaps.pump.carelevo.common.model.UiState +import app.aaps.pump.carelevo.presentation.model.CarelevoOverviewEvent +import app.aaps.pump.carelevo.presentation.type.CarelevoScreenType +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoOverviewViewModel +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import app.aaps.core.ui.R as CoreUiR + +/** + * Tests for [CarelevoComposeContent] — the module's `ComposablePluginContent` entry point. + * + * **Scope caveat (read before extending this file).** `CarelevoComposeContent.Render` cannot be + * rendered by a plain Robolectric Compose test: it resolves its ViewModel with a bare + * `hiltViewModel()` call in the composable body (no parameter to override), and so do the children + * it routes to (`CarelevoAlarmHost`, and four more inside `CarelevoPatchFlowScreen`). Since + * androidx.hilt 1.4 `hiltViewModel()` unconditionally builds a `HiltViewModelFactory` from + * `LocalContext`, which requires an `@AndroidEntryPoint` activity and throws otherwise — there is no + * injection seam, and no amount of `LocalViewModelStoreOwner` seeding avoids it. Covering `Render` + * itself needs a Hilt Robolectric harness (hilt-android-testing + kspTest + `HiltTestApplication` + + * a Hilt test activity), which this module does not have. + * + * What is covered here instead: + * - Construction of the entry point (the only non-composable code in the class). + * - The one route whose content *is* injectable: the `activeWorkflowScreen == null` branch, i.e. + * [CarelevoOverviewScreen], which takes its ViewModel as an explicit parameter. This includes the + * `onStartWorkflow(CONNECTION_FLOW_START)` handshake that `Render`'s `startWorkflow` consumes. + * + * Not covered: the `CONNECTION_FLOW_START` and `else` routes (both delegate to + * `CarelevoPatchFlowScreen`, which hard-calls `hiltViewModel()` four times), and `CarelevoAlarmHost`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class CarelevoComposeContentTest { + + @get:Rule + val compose = createComposeRule() + + private val aapsLogger = mock() + private val carelevoAlarmNotifier = mock() + private val protectionCheck = mock() + private val blePreCheck = mock() + private val iconsProvider = mock() + + /** Not emulating by default — the emulator path skips the BLE pre-check. */ + private val config = mock { + on { isEnabled(ExternalOptions.EMULATE_CARELEVO) } doReturn false + } + + /** Mocked so the screen's snackbar calls are verifiable without depending on snackbar animation/timing. */ + private val snackbarHostState: SnackbarHostState = mock { + onBlocking { showSnackbar(any(), anyOrNull(), any(), any()) } doReturn SnackbarResult.Dismissed + } + + private val events = MutableEventFlow() + private val overviewState = MutableStateFlow(PumpOverviewUiState()) + private val actionState = MutableStateFlow(UiState.Idle) + private val startedWorkflows = mutableListOf() + + private lateinit var viewModel: CarelevoOverviewViewModel + private lateinit var context: Context + + private val confirmLabel get() = context.getString(R.string.carelevo_btn_confirm) + private val cancelLabel get() = context.getString(R.string.carelevo_btn_cancel) + private val discardTitle get() = context.getString(R.string.carelevo_dialog_patch_discard_message_title) + private val resumeTitle get() = context.getString(R.string.carelevo_pump_resume_title) + private val stopDurationTitle get() = context.getString(R.string.carelevo_pump_stop_duration_title) + private val loadingLabel get() = context.getString(CoreUiR.string.loading) + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + viewModel = mock() + whenever(viewModel.overviewUiState).thenReturn(overviewState) + whenever(viewModel.uiState).thenReturn(actionState) + whenever(viewModel.event).thenReturn(events) + whenever(viewModel.isCreated).thenReturn(false) + } + + private fun buildContent(): ComposablePluginContent = CarelevoComposeContent( + aapsLogger = aapsLogger, + carelevoAlarmNotifier = carelevoAlarmNotifier, + protectionCheck = protectionCheck, + blePreCheck = blePreCheck, + iconsProvider = iconsProvider, + config = config + ) + + /** Renders the overview route exactly as `Render` composes it for `activeWorkflowScreen == null`. */ + private fun renderOverviewRoute() { + compose.setContent { + MaterialTheme { + Box { + CarelevoOverviewScreen( + viewModel = viewModel, + snackbarHostState = snackbarHostState, + onStartWorkflow = { startedWorkflows += it } + ) + } + } + } + compose.waitForIdle() + } + + private fun emit(event: CarelevoOverviewEvent) = runBlocking { events.emit(event) } + + private fun assertSnackbarShown(expected: String) = + verifyBlocking(snackbarHostState) { showSnackbar(eq(expected), anyOrNull(), any(), any()) } + + // ── Entry point construction ─────────────────────────────────────────────── + + @Test + fun construction_buildsComposablePluginContent_withoutTouchingCollaborators() { + val content = buildContent() + + assertThat(content).isInstanceOf(CarelevoComposeContent::class.java) + // The plugin builds this eagerly; construction must stay side-effect free. + verifyNoInteractions(aapsLogger, carelevoAlarmNotifier, protectionCheck, blePreCheck, iconsProvider) + } + + // ── Overview route: content ──────────────────────────────────────────────── + + @Test + fun overviewRoute_rendersBanner_queueStatus_infoRows_andActions() { + overviewState.value = PumpOverviewUiState( + statusBanner = StatusBanner(text = "Patch connected", level = StatusLevel.NORMAL), + queueStatus = "Reading status", + infoRows = listOf( + PumpInfoRow(label = "Serial number", value = "04:CD:15:D0:10:05"), + PumpInfoRow(label = "Reservoir", value = "298.0 U", level = StatusLevel.WARNING), + PumpInfoRow(label = "Hidden row", value = "hidden value", visible = false) + ), + primaryActions = listOf(PumpAction(label = "Connect", onClick = {})), + managementActions = listOf(PumpAction(label = "Discard", onClick = {})) + ) + + renderOverviewRoute() + + compose.onNodeWithText("Patch connected").assertIsDisplayed() + compose.onNodeWithText("Reading status").assertIsDisplayed() + compose.onNodeWithText("Serial number").assertIsDisplayed() + compose.onNodeWithText("04:CD:15:D0:10:05").assertIsDisplayed() + compose.onNodeWithText("Reservoir").assertIsDisplayed() + compose.onNodeWithText("298.0 U").assertIsDisplayed() + compose.onNodeWithText("hidden value").assertDoesNotExist() + compose.onNodeWithText("Connect").assertIsDisplayed() + compose.onNodeWithText("Discard").assertIsDisplayed() + } + + @Test + fun overviewRoute_showsNoDialogsOrOverlay_byDefault() { + renderOverviewRoute() + + compose.onNodeWithText(discardTitle).assertDoesNotExist() + compose.onNodeWithText(resumeTitle).assertDoesNotExist() + compose.onNodeWithText(stopDurationTitle).assertDoesNotExist() + compose.onNodeWithText(loadingLabel).assertDoesNotExist() + } + + // ── Overview route: one-time init latch ──────────────────────────────────── + + @Test + fun overviewRoute_runsObserversOnce_whenViewModelNotYetCreated() { + renderOverviewRoute() + + verify(viewModel).observePatchInfo() + verify(viewModel).observePatchState() + verify(viewModel).observeInfusionInfo() + verify(viewModel).observeProfile() + verify(viewModel).setIsCreated(true) + verify(viewModel).refreshPatchInfusionInfo() + } + + @Test + fun overviewRoute_skipsObservers_whenViewModelAlreadyCreated() { + whenever(viewModel.isCreated).thenReturn(true) + + renderOverviewRoute() + + verify(viewModel, never()).observePatchInfo() + verify(viewModel, never()).observePatchState() + verify(viewModel, never()).observeInfusionInfo() + verify(viewModel, never()).observeProfile() + verify(viewModel, never()).setIsCreated(any()) + // The status refresh is unconditional — it must still run on re-entry. + verify(viewModel).refreshPatchInfusionInfo() + } + + // ── Overview route: blocking loading overlay ─────────────────────────────── + + @Test + fun overviewRoute_showsLoadingOverlay_whenActionStateIsLoading() { + // CircularProgressIndicator animates forever — the clock must not auto-advance or the rule + // would never see an idle frame. + compose.mainClock.autoAdvance = false + actionState.value = UiState.Loading + + compose.setContent { + MaterialTheme { + Box { + CarelevoOverviewScreen( + viewModel = viewModel, + snackbarHostState = snackbarHostState, + onStartWorkflow = { startedWorkflows += it } + ) + } + } + } + compose.mainClock.advanceTimeByFrame() + + compose.onNodeWithText(loadingLabel).assertIsDisplayed() + } + + // ── Overview route → Render.startWorkflow handshake ──────────────────────── + + @Test + fun startConnectionFlowEvent_isForwardedAsConnectionFlowStart() { + emit(CarelevoOverviewEvent.StartConnectionFlow) + + renderOverviewRoute() + + assertThat(startedWorkflows).containsExactly(CarelevoScreenType.CONNECTION_FLOW_START) + } + + @Test + fun noActionEvent_startsNoWorkflow_andShowsNoDialog() { + emit(CarelevoOverviewEvent.NoAction) + + renderOverviewRoute() + + assertThat(startedWorkflows).isEmpty() + compose.onNodeWithText(discardTitle).assertDoesNotExist() + compose.onNodeWithText(resumeTitle).assertDoesNotExist() + compose.onNodeWithText(stopDurationTitle).assertDoesNotExist() + } + + @Test + fun clickPumpStopResumeBtnEvent_isConsumedWithoutUiChange() { + emit(CarelevoOverviewEvent.ClickPumpStopResumeBtn) + + renderOverviewRoute() + + // Resolution happens in the VM; the screen must not open a dialog on this event. + assertThat(startedWorkflows).isEmpty() + compose.onNodeWithText(resumeTitle).assertDoesNotExist() + compose.onNodeWithText(stopDurationTitle).assertDoesNotExist() + } + + // ── Overview route: discard dialog ───────────────────────────────────────── + + @Test + fun discardDialog_confirm_startsDiscardProcess_andCloses() { + emit(CarelevoOverviewEvent.ShowPumpDiscardDialog) + renderOverviewRoute() + + compose.onNodeWithText(discardTitle).assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.carelevo_dialog_patch_discard_message_desc)).assertIsDisplayed() + + compose.onNodeWithText(confirmLabel).performClick() + compose.waitForIdle() + + verify(viewModel).startDiscardProcess() + compose.onNodeWithText(discardTitle).assertDoesNotExist() + } + + @Test + fun discardDialog_cancel_closesWithoutDiscarding() { + emit(CarelevoOverviewEvent.ShowPumpDiscardDialog) + renderOverviewRoute() + + compose.onNodeWithText(cancelLabel).performClick() + compose.waitForIdle() + + verify(viewModel, never()).startDiscardProcess() + compose.onNodeWithText(discardTitle).assertDoesNotExist() + } + + // ── Overview route: resume dialog ────────────────────────────────────────── + + @Test + fun resumeDialog_confirm_startsPumpResume_andCloses() { + emit(CarelevoOverviewEvent.ShowPumpResumeDialog) + renderOverviewRoute() + + compose.onNodeWithText(resumeTitle).assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.carelevo_pump_resume_description)).assertIsDisplayed() + + compose.onNodeWithText(confirmLabel).performClick() + compose.waitForIdle() + + verify(viewModel).startPumpResume() + compose.onNodeWithText(resumeTitle).assertDoesNotExist() + } + + @Test + fun resumeDialog_cancel_closesWithoutResuming() { + emit(CarelevoOverviewEvent.ShowPumpResumeDialog) + renderOverviewRoute() + + compose.onNodeWithText(cancelLabel).performClick() + compose.waitForIdle() + + verify(viewModel, never()).startPumpResume() + compose.onNodeWithText(resumeTitle).assertDoesNotExist() + } + + // ── Overview route: stop-duration dialog ─────────────────────────────────── + + @Test + fun stopDurationDialog_confirm_usesFirstDurationByDefault() { + emit(CarelevoOverviewEvent.ShowPumpStopDurationSelectDialog) + renderOverviewRoute() + + compose.onNodeWithText(stopDurationTitle).assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.carelevo_pump_stop_duration_label_30_min)).assertIsDisplayed() + + compose.onNodeWithText(confirmLabel).performClick() + compose.waitForIdle() + + verify(viewModel).startPumpStopProcess(30) + compose.onNodeWithText(stopDurationTitle).assertDoesNotExist() + } + + @Test + fun stopDurationDialog_confirm_usesSelectedDuration() { + emit(CarelevoOverviewEvent.ShowPumpStopDurationSelectDialog) + renderOverviewRoute() + + // Options are ordered 30/60/90/… minutes — index 1 is "1 hour". + compose.onAllNodes(isSelectable())[1].performClick() + compose.waitForIdle() + compose.onAllNodes(isSelectable())[1].assertIsSelected() + + compose.onNodeWithText(confirmLabel).performClick() + compose.waitForIdle() + + verify(viewModel).startPumpStopProcess(60) + verify(viewModel, never()).startPumpStopProcess(30) + } + + @Test + fun stopDurationDialog_cancel_closesWithoutStopping() { + emit(CarelevoOverviewEvent.ShowPumpStopDurationSelectDialog) + renderOverviewRoute() + + compose.onNodeWithText(cancelLabel).performClick() + compose.waitForIdle() + + verify(viewModel, never()).startPumpStopProcess(any()) + compose.onNodeWithText(stopDurationTitle).assertDoesNotExist() + } + + // ── Overview route: snackbar events ──────────────────────────────────────── + + @Test + fun bluetoothNotEnabledEvent_showsSnackbar() { + emit(CarelevoOverviewEvent.ShowMessageBluetoothNotEnabled) + renderOverviewRoute() + + assertSnackbarShown(context.getString(R.string.carelevo_toast_msg_bluetooth_not_enabled)) + } + + @Test + fun notConnectedEvent_showsSnackbar() { + emit(CarelevoOverviewEvent.ShowMessageCarelevoIsNotConnected) + renderOverviewRoute() + + assertSnackbarShown(context.getString(R.string.carelevo_toast_msg_patch_not_connected)) + } + + @Test + fun discardFailedEvent_showsSnackbar() { + emit(CarelevoOverviewEvent.DiscardFailed) + renderOverviewRoute() + + assertSnackbarShown(context.getString(R.string.carelevo_toast_msg_discard_failed)) + } + + @Test + fun resumePumpFailedEvent_showsSnackbar() { + emit(CarelevoOverviewEvent.ResumePumpFailed) + renderOverviewRoute() + + assertSnackbarShown(context.getString(R.string.carelevo_toast_mag_set_basal_resume_fail)) + } + + @Test + fun stopPumpFailedEvent_showsSnackbar() { + emit(CarelevoOverviewEvent.StopPumpFailed) + renderOverviewRoute() + + assertSnackbarShown(context.getString(R.string.carelevo_toast_mag_set_basal_suspend_fail)) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/CarelevoOverviewScreenTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/CarelevoOverviewScreenTest.kt new file mode 100644 index 000000000000..f44cce485da2 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/CarelevoOverviewScreenTest.kt @@ -0,0 +1,561 @@ +package app.aaps.pump.carelevo.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.isSelectable +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import app.aaps.core.ui.compose.StatusLevel +import app.aaps.core.ui.compose.pump.ActionCategory +import app.aaps.core.ui.compose.pump.PumpAction +import app.aaps.core.ui.compose.pump.PumpInfoRow +import app.aaps.core.ui.compose.pump.PumpOverviewUiState +import app.aaps.core.ui.compose.pump.StatusBanner +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.common.MutableEventFlow +import app.aaps.pump.carelevo.common.asEventFlow +import app.aaps.pump.carelevo.common.model.Event +import app.aaps.pump.carelevo.common.model.State +import app.aaps.pump.carelevo.common.model.UiState +import app.aaps.pump.carelevo.presentation.model.CarelevoOverviewEvent +import app.aaps.pump.carelevo.presentation.type.CarelevoScreenType +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoOverviewViewModel +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import app.aaps.core.ui.R as CoreUiR + +/** + * UI tests for [CarelevoOverviewScreen] — the Carelevo-specific glue around the shared + * [app.aaps.core.ui.compose.pump.PumpOverviewScreen]: the one-shot event router (snackbars, the three + * dialogs, the connection-flow hand-off) and the blocking progress overlay. + * + * The screen takes its [CarelevoOverviewViewModel] as an explicit parameter (no `hiltViewModel()` + * default), so a Mockito mock backed by real `MutableStateFlow`s / a real `MutableEventFlow` drives + * every branch without any of the pump's BLE collaborators. The rendering of the state itself + * (banner/rows/buttons) is covered at its home in `:core:ui` by `PumpOverviewScreenTest`; here we + * assert only that Carelevo hands its state through and reacts to events correctly. + * + * [MaterialTheme] is enough — the screen and its dialogs read `MaterialTheme.colorScheme` only, never + * `AapsTheme` values. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +// Phone-sized viewport: the stop-duration dialog lays its 8 options out in a plain (non-scrolling) +// Column inside AlertDialog's text slot, so on Robolectric's small default screen the last options +// are clipped to zero height and can be neither asserted as displayed nor clicked. +@Config(sdk = [35], qualifiers = "w411dp-h891dp") +class CarelevoOverviewScreenTest { + + @get:Rule + val compose = createComposeRule() + + private val context = RuntimeEnvironment.getApplication() + + private val overviewState = MutableStateFlow(PumpOverviewUiState()) + private val actionState = MutableStateFlow(UiState.Idle) + private val events = MutableEventFlow() + private val snackbarHostState = SnackbarHostState() + private val startedWorkflows = mutableListOf() + + private val viewModel = mock() + + // ── strings ──────────────────────────────────────────────────────────────── + private val confirm = context.getString(R.string.carelevo_btn_confirm) + private val cancel = context.getString(R.string.carelevo_btn_cancel) + private val loading = context.getString(CoreUiR.string.loading) + private val discardTitle = context.getString(R.string.carelevo_dialog_patch_discard_message_title) + private val discardDesc = context.getString(R.string.carelevo_dialog_patch_discard_message_desc) + private val resumeTitle = context.getString(R.string.carelevo_pump_resume_title) + private val resumeDesc = context.getString(R.string.carelevo_pump_resume_description) + private val stopDurationTitle = context.getString(R.string.carelevo_pump_stop_duration_title) + private val label30Min = context.getString(R.string.carelevo_pump_stop_duration_label_30_min) + private val label4Hour = context.getString(R.string.carelevo_pump_stop_duration_label_4_hour) + + @Before + fun setUp() { + whenever(viewModel.overviewUiState).thenReturn(overviewState) + whenever(viewModel.uiState).thenReturn(actionState) + whenever(viewModel.event).thenReturn(events.asEventFlow()) + } + + // ── harness ──────────────────────────────────────────────────────────────── + + /** Hosts the screen next to a [SnackbarHost] so event-driven messages are assertable as text. */ + private fun setContent() { + compose.setContent { + MaterialTheme { + Box { + CarelevoOverviewScreen( + viewModel = viewModel, + snackbarHostState = snackbarHostState, + onStartWorkflow = { startedWorkflows += it } + ) + SnackbarHost(hostState = snackbarHostState) + } + } + } + compose.waitForIdle() + } + + /** + * Emits one event and lets the screen's collector drain it. One event at a time: the underlying + * shared flow has replay=1 / no extra buffer, so emitting twice without draining would suspend — + * and Robolectric runs this test on the main thread that the collector needs. + */ + private fun emit(event: CarelevoOverviewEvent) { + runBlocking { events.emit(event) } + compose.waitForIdle() + } + + private fun stateWith(vararg actions: PumpAction) = PumpOverviewUiState( + statusBanner = StatusBanner(text = "Connected", level = StatusLevel.NORMAL), + managementActions = actions.toList() + ) + + // ── bootstrap (LaunchedEffect) ───────────────────────────────────────────── + + @Test + fun firstComposition_startsObservers_andRefreshes_whenNotYetCreated() { + whenever(viewModel.isCreated).thenReturn(false) + + setContent() + + verify(viewModel).observePatchInfo() + verify(viewModel).observePatchState() + verify(viewModel).observeInfusionInfo() + verify(viewModel).observeProfile() + verify(viewModel).setIsCreated(true) + verify(viewModel).refreshPatchInfusionInfo() + } + + @Test + fun firstComposition_skipsObservers_butStillRefreshes_whenAlreadyCreated() { + whenever(viewModel.isCreated).thenReturn(true) + + setContent() + + verify(viewModel, never()).observePatchInfo() + verify(viewModel, never()).observePatchState() + verify(viewModel, never()).observeInfusionInfo() + verify(viewModel, never()).observeProfile() + verify(viewModel, never()).setIsCreated(true) + verify(viewModel).refreshPatchInfusionInfo() + } + + // ── state pass-through ───────────────────────────────────────────────────── + + @Test + fun rendersBannerAndInfoRows_fromViewModelState() { + // Banner and row values are deliberately distinct strings: onNodeWithText fails on 2 matches. + overviewState.value = PumpOverviewUiState( + statusBanner = StatusBanner(text = "Patch connected", level = StatusLevel.NORMAL), + queueStatus = "Reading status", + infoRows = listOf( + PumpInfoRow(label = context.getString(R.string.carelevo_bluetooth_state_key), value = "Connected"), + PumpInfoRow(label = context.getString(R.string.carelevo_serial_number_key), value = "04:CD:15:D0:10:05") + ) + ) + + setContent() + + compose.onNodeWithText("Patch connected").assertIsDisplayed() + compose.onNodeWithText("Connected").assertIsDisplayed() + compose.onNodeWithText("Reading status").assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.carelevo_bluetooth_state_key)).assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.carelevo_serial_number_key)).assertIsDisplayed() + compose.onNodeWithText("04:CD:15:D0:10:05").assertIsDisplayed() + } + + @Test + fun noActivePatch_showsWarningBannerAndConnectAction_butNoManagementActions() { + val connectLabel = context.getString(R.string.carelevo_overview_connect_btn_label) + overviewState.value = PumpOverviewUiState( + statusBanner = StatusBanner(text = context.getString(R.string.carelevo_state_none_value), level = StatusLevel.WARNING), + primaryActions = listOf(PumpAction(label = connectLabel, category = ActionCategory.PRIMARY, onClick = {})) + ) + + setContent() + + compose.onNodeWithText(context.getString(R.string.carelevo_state_none_value)).assertIsDisplayed() + compose.onNodeWithText(connectLabel).assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.carelevo_overview_pump_discard_btn_label)).assertDoesNotExist() + compose.onNodeWithText(context.getString(CoreUiR.string.pump_suspend)).assertDoesNotExist() + } + + @Test + fun recomposes_whenViewModelStateChanges() { + overviewState.value = PumpOverviewUiState(statusBanner = StatusBanner(text = "No Active Patch", level = StatusLevel.WARNING)) + setContent() + compose.onNodeWithText("No Active Patch").assertIsDisplayed() + + overviewState.value = PumpOverviewUiState(statusBanner = StatusBanner(text = "Connected", level = StatusLevel.NORMAL)) + compose.waitForIdle() + + compose.onNodeWithText("Connected").assertIsDisplayed() + compose.onNodeWithText("No Active Patch").assertDoesNotExist() + } + + @Test + fun actionClick_invokesActionCallback() { + var clicks = 0 + overviewState.value = stateWith( + PumpAction(label = "Discard Patch", category = ActionCategory.MANAGEMENT, onClick = { clicks++ }) + ) + + setContent() + compose.onNodeWithText("Discard Patch").assertIsEnabled().performClick() + compose.waitForIdle() + + assertThat(clicks).isEqualTo(1) + } + + @Test + fun disabledAction_isRenderedButNotEnabled() { + overviewState.value = stateWith( + PumpAction(label = "Discard Patch", category = ActionCategory.MANAGEMENT, enabled = false, onClick = {}) + ) + + setContent() + + compose.onNodeWithText("Discard Patch").assertIsDisplayed().assertIsNotEnabled() + } + + // ── loading overlay ──────────────────────────────────────────────────────── + + @Test + fun loadingOverlay_hidden_whenActionStateIdle() { + actionState.value = UiState.Idle + + setContent() + + compose.onNodeWithText(loading).assertDoesNotExist() + } + + @Test + fun loadingOverlay_shown_whenActionStateLoading() { + actionState.value = UiState.Loading + + setContent() + + compose.onNodeWithText(loading).assertIsDisplayed() + } + + @Test + fun loadingOverlay_appearsAndDisappears_withActionState() { + setContent() + compose.onNodeWithText(loading).assertDoesNotExist() + + actionState.value = UiState.Loading + compose.waitForIdle() + compose.onNodeWithText(loading).assertIsDisplayed() + + actionState.value = UiState.Idle + compose.waitForIdle() + compose.onNodeWithText(loading).assertDoesNotExist() + } + + @Test + fun loadingOverlay_consumesTaps_soActionUnderneathCannotReFire() { + var clicks = 0 + overviewState.value = stateWith( + PumpAction(label = "Discard Patch", category = ActionCategory.MANAGEMENT, onClick = { clicks++ }) + ) + actionState.value = UiState.Loading + + setContent() + compose.onNodeWithText("Discard Patch").performClick() + compose.waitForIdle() + + assertThat(clicks).isEqualTo(0) + } + + // ── event → snackbar ─────────────────────────────────────────────────────── + + @Test + fun event_showMessageBluetoothNotEnabled_showsSnackbar() { + setContent() + + emit(CarelevoOverviewEvent.ShowMessageBluetoothNotEnabled) + + compose.onNodeWithText(context.getString(R.string.carelevo_toast_msg_bluetooth_not_enabled)).assertIsDisplayed() + } + + @Test + fun event_carelevoIsNotConnected_showsSnackbar() { + setContent() + + emit(CarelevoOverviewEvent.ShowMessageCarelevoIsNotConnected) + + compose.onNodeWithText(context.getString(R.string.carelevo_toast_msg_patch_not_connected)).assertIsDisplayed() + } + + @Test + fun event_discardFailed_showsSnackbar() { + setContent() + + emit(CarelevoOverviewEvent.DiscardFailed) + + compose.onNodeWithText(context.getString(R.string.carelevo_toast_msg_discard_failed)).assertIsDisplayed() + } + + @Test + fun event_resumePumpFailed_showsSnackbar() { + setContent() + + emit(CarelevoOverviewEvent.ResumePumpFailed) + + compose.onNodeWithText(context.getString(R.string.carelevo_toast_mag_set_basal_resume_fail)).assertIsDisplayed() + } + + @Test + fun event_stopPumpFailed_showsSnackbar() { + setContent() + + emit(CarelevoOverviewEvent.StopPumpFailed) + + compose.onNodeWithText(context.getString(R.string.carelevo_toast_mag_set_basal_suspend_fail)).assertIsDisplayed() + } + + // ── event → connection flow ──────────────────────────────────────────────── + + @Test + fun event_startConnectionFlow_hostsWorkflow() { + setContent() + + emit(CarelevoOverviewEvent.StartConnectionFlow) + + assertThat(startedWorkflows).containsExactly(CarelevoScreenType.CONNECTION_FLOW_START) + } + + @Test + fun event_noAction_doesNothing() { + setContent() + + emit(CarelevoOverviewEvent.NoAction) + + assertThat(startedWorkflows).isEmpty() + compose.onNodeWithText(discardTitle).assertDoesNotExist() + compose.onNodeWithText(resumeTitle).assertDoesNotExist() + compose.onNodeWithText(stopDurationTitle).assertDoesNotExist() + } + + @Test + fun event_clickPumpStopResumeBtn_doesNothing_asItIsResolvedInTheViewModel() { + setContent() + + emit(CarelevoOverviewEvent.ClickPumpStopResumeBtn) + + assertThat(startedWorkflows).isEmpty() + compose.onNodeWithText(resumeTitle).assertDoesNotExist() + compose.onNodeWithText(stopDurationTitle).assertDoesNotExist() + } + + // ── discard dialog ───────────────────────────────────────────────────────── + + @Test + fun discardDialog_notShownInitially() { + setContent() + + compose.onNodeWithText(discardTitle).assertDoesNotExist() + } + + @Test + fun event_showPumpDiscardDialog_showsDialog() { + setContent() + + emit(CarelevoOverviewEvent.ShowPumpDiscardDialog) + + compose.onNodeWithText(discardTitle).assertIsDisplayed() + compose.onNodeWithText(discardDesc).assertIsDisplayed() + compose.onNodeWithText(confirm).assertIsDisplayed() + compose.onNodeWithText(cancel).assertIsDisplayed() + } + + @Test + fun discardDialog_confirm_startsDiscardAndDismisses() { + setContent() + emit(CarelevoOverviewEvent.ShowPumpDiscardDialog) + + compose.onNodeWithText(confirm).performClick() + compose.waitForIdle() + + verify(viewModel).startDiscardProcess() + compose.onNodeWithText(discardTitle).assertDoesNotExist() + } + + @Test + fun discardDialog_cancel_dismissesWithoutDiscarding() { + setContent() + emit(CarelevoOverviewEvent.ShowPumpDiscardDialog) + + compose.onNodeWithText(cancel).performClick() + compose.waitForIdle() + + verify(viewModel, never()).startDiscardProcess() + compose.onNodeWithText(discardTitle).assertDoesNotExist() + } + + // ── resume dialog ────────────────────────────────────────────────────────── + + @Test + fun event_showPumpResumeDialog_showsDialog() { + setContent() + + emit(CarelevoOverviewEvent.ShowPumpResumeDialog) + + compose.onNodeWithText(resumeTitle).assertIsDisplayed() + compose.onNodeWithText(resumeDesc).assertIsDisplayed() + } + + @Test + fun resumeDialog_confirm_startsResumeAndDismisses() { + setContent() + emit(CarelevoOverviewEvent.ShowPumpResumeDialog) + + compose.onNodeWithText(confirm).performClick() + compose.waitForIdle() + + verify(viewModel).startPumpResume() + compose.onNodeWithText(resumeTitle).assertDoesNotExist() + } + + @Test + fun resumeDialog_cancel_dismissesWithoutResuming() { + setContent() + emit(CarelevoOverviewEvent.ShowPumpResumeDialog) + + compose.onNodeWithText(cancel).performClick() + compose.waitForIdle() + + verify(viewModel, never()).startPumpResume() + compose.onNodeWithText(resumeTitle).assertDoesNotExist() + } + + // ── stop-duration dialog ─────────────────────────────────────────────────── + + @Test + fun event_showPumpStopDurationSelectDialog_showsAllDurationOptions() { + setContent() + + emit(CarelevoOverviewEvent.ShowPumpStopDurationSelectDialog) + + compose.onNodeWithText(stopDurationTitle).assertIsDisplayed() + listOf( + R.string.carelevo_pump_stop_duration_label_30_min, + R.string.carelevo_pump_stop_duration_label_1_hour, + R.string.carelevo_pump_stop_duration_label_1_hour_30_min, + R.string.carelevo_pump_stop_duration_label_2_hour, + R.string.carelevo_pump_stop_duration_label_2_hour_30_min, + R.string.carelevo_pump_stop_duration_label_3_hour, + R.string.carelevo_pump_stop_duration_label_3_hour_30_min, + R.string.carelevo_pump_stop_duration_label_4_hour + ).forEach { compose.onNodeWithText(context.getString(it)).assertIsDisplayed() } + compose.onAllNodes(isSelectable()).assertCountEquals(8) + } + + @Test + fun stopDurationDialog_firstOptionSelectedByDefault() { + setContent() + + emit(CarelevoOverviewEvent.ShowPumpStopDurationSelectDialog) + + compose.onAllNodes(isSelectable())[0].assertIsSelected() + compose.onAllNodes(isSelectable())[7].assertIsNotSelected() + compose.onNodeWithText(label30Min).assertIsDisplayed() + } + + @Test + fun stopDurationDialog_confirmWithDefault_stopsFor30Minutes() { + setContent() + emit(CarelevoOverviewEvent.ShowPumpStopDurationSelectDialog) + + compose.onNodeWithText(confirm).performClick() + compose.waitForIdle() + + verify(viewModel).startPumpStopProcess(30) + compose.onNodeWithText(stopDurationTitle).assertDoesNotExist() + } + + @Test + fun stopDurationDialog_selectingLastOption_stopsFor240Minutes() { + setContent() + emit(CarelevoOverviewEvent.ShowPumpStopDurationSelectDialog) + + compose.onAllNodes(isSelectable())[7].performClick() + compose.waitForIdle() + compose.onAllNodes(isSelectable())[7].assertIsSelected() + compose.onAllNodes(isSelectable())[0].assertIsNotSelected() + compose.onNodeWithText(label4Hour).assertIsDisplayed() + + compose.onNodeWithText(confirm).performClick() + compose.waitForIdle() + + verify(viewModel).startPumpStopProcess(240) + compose.onNodeWithText(stopDurationTitle).assertDoesNotExist() + } + + @Test + fun stopDurationDialog_selectingMiddleOption_stopsForMatchingDuration() { + setContent() + emit(CarelevoOverviewEvent.ShowPumpStopDurationSelectDialog) + + // index 3 → "2 hours" → 120 minutes + compose.onAllNodes(isSelectable())[3].performClick() + compose.waitForIdle() + + compose.onNodeWithText(confirm).performClick() + compose.waitForIdle() + + verify(viewModel).startPumpStopProcess(120) + } + + @Test + fun stopDurationDialog_cancel_dismissesWithoutStopping() { + setContent() + emit(CarelevoOverviewEvent.ShowPumpStopDurationSelectDialog) + + compose.onNodeWithText(cancel).performClick() + compose.waitForIdle() + + verify(viewModel, never()).startPumpStopProcess(any()) + compose.onNodeWithText(stopDurationTitle).assertDoesNotExist() + } + + @Test + fun stopDurationDialog_reopensWithSelectionResetToDefault_afterCancellingANonDefaultChoice() { + setContent() + emit(CarelevoOverviewEvent.ShowPumpStopDurationSelectDialog) + compose.onAllNodes(isSelectable())[7].performClick() + compose.waitForIdle() + compose.onNodeWithText(cancel).performClick() + compose.waitForIdle() + + emit(CarelevoOverviewEvent.ShowPumpStopDurationSelectDialog) + + compose.onAllNodes(isSelectable())[0].assertIsSelected() + compose.onAllNodes(isSelectable())[7].assertIsNotSelected() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/alarm/CarelevoAlarmHostTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/alarm/CarelevoAlarmHostTest.kt new file mode 100644 index 000000000000..82984ade055b --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/alarm/CarelevoAlarmHostTest.kt @@ -0,0 +1,437 @@ +package app.aaps.pump.carelevo.compose.alarm + +import android.content.Context +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import app.aaps.core.interfaces.ui.IconsProvider +import app.aaps.core.ui.R as CoreUiR +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.type.AlarmCause +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Tests for the alarm host file — its cause→[CarelevoAlarmUiModel] mapping and the rendering that + * mapping drives through [CarelevoAlarmScreen]. + * + * ## What is NOT covered, and why + * The [CarelevoAlarmHost] composable body itself is NOT rendered here. It resolves its ViewModel + * with a hard-coded `hiltViewModel()` call and takes no ViewModel parameter, so there is no seam to + * inject a test double through. That call is not defeatable from a plain Robolectric test: + * `hiltViewModel()` (androidx.hilt 1.4.0) unconditionally builds a `HiltViewModelFactory` from + * `LocalContext` *before* it consults the ViewModelStore, and that factory walks the context chain + * to a `ComponentActivity` and demands an `@AndroidEntryPoint` entry point on it. The plain + * `ComponentActivity` behind `createComposeRule()` is not one, so composing the host throws + * regardless of what `LocalViewModelStoreOwner` provides. Rendering it would need the full Hilt test + * graph (`HiltTestApplication` + a Hilt runner + every real collaborator of `CarelevoAlarmViewModel`) + * — a build-level change, not a test-level one. + * + * So the host's effect wiring (the `alarmHostActive` `DisposableEffect`, the notifier→ + * `loadActiveAlarms` bridge, the event/snackbar/StartAlarm `LaunchedEffect`s and the dismissed-id + * bookkeeping) is out of reach here. Its two pure helpers — `buildAlarmUiModel` and + * `buildDescArgsFor` — carry the file's real branching, and they are reachable: private top-level + * functions compile to static methods on `CarelevoAlarmHostKt`, so they are driven reflectively + * against a real Robolectric [Context] with real string resources. Reflection into the host's own + * privates matches the approach the sibling `CarelevoAlarmViewModelTest` already takes. + * + * The render cases feed a model built by the *real* helper into the screen, so the + * mapping→UI contract is asserted end-to-end rather than against a hand-written model. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class CarelevoAlarmHostTest { + + @get:Rule + val compose = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + /** The host reads only [IconsProvider.getIcon]; a fake keeps the icon value assertable. */ + private class FakeIconsProvider(private val icon: Int) : IconsProvider { + + override fun getIcon(): Int = icon + override fun getNotificationIcon(): Int = icon + } + + private val appIcon = R.drawable.ic_carelevo_128 + + private fun alarm( + cause: AlarmCause, + value: Int? = null, + id: String = "alarm-1" + ): CarelevoAlarmInfo = CarelevoAlarmInfo( + alarmId = id, + alarmType = cause.alarmType, + cause = cause, + value = value, + createdAt = "2026-07-16T10:00:00", + updatedAt = "2026-07-16T10:00:00", + isAcknowledged = false + ) + + // ---- reflection into the host file's private top-level helpers ----------------------------- + + private val hostFileClass: Class<*> = Class.forName("app.aaps.pump.carelevo.compose.alarm.CarelevoAlarmHostKt") + + private fun buildAlarmUiModel(alarm: CarelevoAlarmInfo): CarelevoAlarmUiModel { + val method = hostFileClass.getDeclaredMethod( + "buildAlarmUiModel", + Context::class.java, + IconsProvider::class.java, + CarelevoAlarmInfo::class.java + ) + method.isAccessible = true + return method.invoke(null, context, FakeIconsProvider(appIcon), alarm) as CarelevoAlarmUiModel + } + + @Suppress("UNCHECKED_CAST") + private fun buildDescArgsFor(alarm: CarelevoAlarmInfo): List { + val method = hostFileClass.getDeclaredMethod( + "buildDescArgsFor", + Context::class.java, + CarelevoAlarmInfo::class.java + ) + method.isAccessible = true + return method.invoke(null, context, alarm) as List + } + + // ---- buildDescArgsFor: insulin causes ------------------------------------------------------ + + @Test + fun descArgs_noticeLowInsulin_passesTheRemainingUnits() { + assertThat(buildDescArgsFor(alarm(AlarmCause.ALARM_NOTICE_LOW_INSULIN, value = 20))) + .containsExactly("20") + } + + @Test + fun descArgs_noticeLowInsulin_nullValueFallsBackToZero() { + assertThat(buildDescArgsFor(alarm(AlarmCause.ALARM_NOTICE_LOW_INSULIN, value = null))) + .containsExactly("0") + } + + @Test + fun descArgs_alertOutOfInsulin_sharesTheLowInsulinBranch() { + assertThat(buildDescArgsFor(alarm(AlarmCause.ALARM_ALERT_OUT_OF_INSULIN, value = 5))) + .containsExactly("5") + } + + // ---- buildDescArgsFor: patch expired (hours -> days + hours) ------------------------------- + + @Test + fun descArgs_noticePatchExpired_splitsTotalHoursIntoDaysAndHours() { + // 50h -> 2 days, 2 hours + assertThat(buildDescArgsFor(alarm(AlarmCause.ALARM_NOTICE_PATCH_EXPIRED, value = 50))) + .containsExactly("2", "2") + .inOrder() + } + + @Test + fun descArgs_noticePatchExpired_wholeDaysReportZeroHours() { + assertThat(buildDescArgsFor(alarm(AlarmCause.ALARM_NOTICE_PATCH_EXPIRED, value = 48))) + .containsExactly("2", "0") + .inOrder() + } + + @Test + fun descArgs_noticePatchExpired_nullValueFallsBackToZeros() { + assertThat(buildDescArgsFor(alarm(AlarmCause.ALARM_NOTICE_PATCH_EXPIRED, value = null))) + .containsExactly("0", "0") + .inOrder() + } + + // ---- buildDescArgsFor: BG check (minutes -> localized duration) ---------------------------- + + @Test + fun descArgs_noticeBgCheck_formatsHoursAndMinutes() { + // 150min -> 2h 30m: both parts non-zero -> the combined hour+minute template. + assertThat(buildDescArgsFor(alarm(AlarmCause.ALARM_NOTICE_BG_CHECK, value = 150))) + .containsExactly(context.getString(R.string.common_label_unit_value_duration_hour_and_minute, 2, 30)) + } + + @Test + fun descArgs_noticeBgCheck_wholeHoursDropTheMinutePart() { + assertThat(buildDescArgsFor(alarm(AlarmCause.ALARM_NOTICE_BG_CHECK, value = 120))) + .containsExactly(context.getString(R.string.common_label_unit_value_duration_hour, 2)) + } + + @Test + fun descArgs_noticeBgCheck_underAnHourUsesTheMinuteTemplate() { + assertThat(buildDescArgsFor(alarm(AlarmCause.ALARM_NOTICE_BG_CHECK, value = 45))) + .containsExactly(context.getString(R.string.common_label_unit_value_minute, 45)) + } + + @Test + fun descArgs_noticeBgCheck_nullValueRendersZeroMinutes() { + assertThat(buildDescArgsFor(alarm(AlarmCause.ALARM_NOTICE_BG_CHECK, value = null))) + .containsExactly(context.getString(R.string.common_label_unit_value_minute, 0)) + } + + // ---- buildDescArgsFor: causes that take no arguments --------------------------------------- + + @Test + fun descArgs_causeWithoutPlaceholders_returnsNoArgs() { + assertThat(buildDescArgsFor(alarm(AlarmCause.ALARM_NOTICE_LGS_START))).isEmpty() + } + + @Test + fun descArgs_warningCause_returnsNoArgs() { + assertThat(buildDescArgsFor(alarm(AlarmCause.ALARM_WARNING_PUMP_CLOGGED))).isEmpty() + } + + // ---- buildAlarmUiModel --------------------------------------------------------------------- + + @Test + fun uiModel_noticeLowInsulin_mapsEveryField() { + val model = buildAlarmUiModel(alarm(AlarmCause.ALARM_NOTICE_LOW_INSULIN, value = 20)) + + assertThat(model.appIcon).isEqualTo(appIcon) + assertThat(model.title).isEqualTo(context.getString(R.string.alarm_feat_title_notice_low_insulin)) + assertThat(model.content).isEqualTo(context.getString(R.string.alarm_feat_desc_notice_low_insulin, "20")) + assertThat(model.primaryButtonText).isEqualTo(context.getString(R.string.common_btn_ok)) + assertThat(model.muteButtonText).isEqualTo(context.getString(CoreUiR.string.mute)) + assertThat(model.mute5minButtonText).isEqualTo(context.getString(CoreUiR.string.mute5min)) + } + + @Test + fun uiModel_noticeLowInsulin_contentCarriesTheRemainingUnits() { + val model = buildAlarmUiModel(alarm(AlarmCause.ALARM_NOTICE_LOW_INSULIN, value = 20)) + + assertThat(model.content).contains("20") + } + + @Test + fun uiModel_causeWithoutDescription_producesBlankContent() { + // ALARM_NOTICE_ATTACH_PATCH_CHECK maps to a null screen description -> the `?: ""` fallback. + val model = buildAlarmUiModel(alarm(AlarmCause.ALARM_NOTICE_ATTACH_PATCH_CHECK)) + + assertThat(model.content).isEmpty() + assertThat(model.title).isEqualTo(context.getString(R.string.alarm_feat_title_notice_check_patch)) + } + + @Test + fun uiModel_warningCauseWithoutDescription_producesBlankContent() { + val model = buildAlarmUiModel(alarm(AlarmCause.ALARM_WARNING_BLE_NOT_CONNECTED)) + + assertThat(model.content).isEmpty() + assertThat(model.title).isEqualTo(context.getString(R.string.alarm_feat_title_warning_not_connected_ble)) + assertThat(model.primaryButtonText).isEqualTo(context.getString(R.string.alarm_feat_btn_patch_force_discard)) + } + + @Test + fun uiModel_descriptionWithoutArgs_usesThePlainString() { + // Empty descArgs -> getString(resId) with no formatting applied. + val model = buildAlarmUiModel(alarm(AlarmCause.ALARM_NOTICE_LGS_START)) + + assertThat(model.content).isEqualTo(context.getString(R.string.alarm_feat_desc_notice_lgs_started)) + } + + @Test + fun uiModel_noticePatchExpired_formatsDaysAndHoursIntoTheDescription() { + val model = buildAlarmUiModel(alarm(AlarmCause.ALARM_NOTICE_PATCH_EXPIRED, value = 50)) + + assertThat(model.content) + .isEqualTo(context.getString(R.string.alarm_feat_desc_notice_expired_patch, "2", "2")) + } + + @Test + fun uiModel_noticeBgCheck_formatsTheElapsedDurationIntoTheDescription() { + val model = buildAlarmUiModel(alarm(AlarmCause.ALARM_NOTICE_BG_CHECK, value = 150)) + + val duration = context.getString(R.string.common_label_unit_value_duration_hour_and_minute, 2, 30) + assertThat(model.content).isEqualTo(context.getString(R.string.alarm_feat_desc_notice_check_bg, duration)) + } + + @Test + fun uiModel_alertOutOfInsulin_usesTheAlertTierStrings() { + val model = buildAlarmUiModel(alarm(AlarmCause.ALARM_ALERT_OUT_OF_INSULIN, value = 5)) + + assertThat(model.title).isEqualTo(context.getString(R.string.alarm_feat_title_alert_low_insulin)) + assertThat(model.content).isEqualTo(context.getString(R.string.alarm_feat_desc_alert_low_insulin, "5")) + assertThat(model.primaryButtonText).isEqualTo(context.getString(R.string.common_btn_ok)) + } + + @Test + fun uiModel_warningLowBattery_usesTheForceDiscardButton() { + val model = buildAlarmUiModel(alarm(AlarmCause.ALARM_WARNING_LOW_BATTERY)) + + assertThat(model.title).isEqualTo(context.getString(R.string.alarm_feat_title_warning_low_battery)) + assertThat(model.primaryButtonText).isEqualTo(context.getString(R.string.alarm_feat_btn_patch_force_discard)) + } + + @Test + fun uiModel_unknownCause_stillMapsToTheUnknownStrings() { + val model = buildAlarmUiModel(alarm(AlarmCause.ALARM_UNKNOWN)) + + assertThat(model.title).isEqualTo(context.getString(R.string.alarm_feat_title_notice_unknown)) + assertThat(model.content).isEqualTo(context.getString(R.string.alarm_feat_desc_unknown)) + } + + // ---- the built model rendered through the screen the host drives --------------------------- + + @Test + fun render_builtModel_showsTitleAndAllThreeButtons() { + val model = buildAlarmUiModel(alarm(AlarmCause.ALARM_NOTICE_LOW_INSULIN, value = 20)) + + compose.setContent { + MaterialTheme { + CarelevoAlarmScreen( + alarm = model, + onPrimaryClick = {}, + onMuteClick = {}, + onMute5MinClick = {} + ) + } + } + + compose.onNodeWithText(context.getString(R.string.alarm_feat_title_notice_low_insulin)).assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.common_btn_ok)).assertIsDisplayed() + compose.onNodeWithText(context.getString(CoreUiR.string.mute)).assertIsDisplayed() + compose.onNodeWithText(context.getString(CoreUiR.string.mute5min)).assertIsDisplayed() + } + + @Test + fun render_builtModel_showsTheFormattedDescription() { + val model = buildAlarmUiModel(alarm(AlarmCause.ALARM_NOTICE_BG_CHECK, value = 45)) + + compose.setContent { + MaterialTheme { + CarelevoAlarmScreen( + alarm = model, + onPrimaryClick = {}, + onMuteClick = {}, + onMute5MinClick = {} + ) + } + } + + // The screen parses the description as HTML, so match on the plain-text head of it. + val minutes = context.getString(R.string.common_label_unit_value_minute, 45) + compose.onNodeWithText(minutes, substring = true).assertIsDisplayed() + } + + @Test + fun render_nullAlarm_showsNothing() { + compose.setContent { + MaterialTheme { + CarelevoAlarmScreen( + alarm = null, + onPrimaryClick = {}, + onMuteClick = {}, + onMute5MinClick = {} + ) + } + } + + compose.onNodeWithText(context.getString(CoreUiR.string.mute)).assertDoesNotExist() + compose.onNodeWithText(context.getString(R.string.common_btn_ok)).assertDoesNotExist() + } + + @Test + fun render_blankContent_omitsTheDescriptionButKeepsTitleAndButtons() { + // ALARM_NOTICE_ATTACH_PATCH_CHECK has no screen description -> content is blank, so the + // screen's `if (alarm.content.isNotBlank())` guard must skip the description Text entirely. + val model = buildAlarmUiModel(alarm(AlarmCause.ALARM_NOTICE_ATTACH_PATCH_CHECK)) + assertThat(model.content).isEmpty() + + compose.setContent { + MaterialTheme { + CarelevoAlarmScreen( + alarm = model, + onPrimaryClick = {}, + onMuteClick = {}, + onMute5MinClick = {} + ) + } + } + + // Only the title and the three buttons survive; no description node is emitted. + compose.onAllNodesWithText(context.getString(R.string.alarm_feat_title_notice_check_patch)) + .assertCountEquals(1) + compose.onNodeWithText(context.getString(R.string.alarm_feat_title_notice_check_patch)).assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.common_btn_ok)).assertIsDisplayed() + compose.onNodeWithText(context.getString(CoreUiR.string.mute)).assertIsDisplayed() + compose.onNodeWithText(context.getString(CoreUiR.string.mute5min)).assertIsDisplayed() + } + + @Test + fun render_primaryButton_firesTheHostsClearCallback() { + var primaryClicks = 0 + val model = buildAlarmUiModel(alarm(AlarmCause.ALARM_NOTICE_LOW_INSULIN, value = 20)) + + compose.setContent { + MaterialTheme { + CarelevoAlarmScreen( + alarm = model, + onPrimaryClick = { primaryClicks++ }, + onMuteClick = {}, + onMute5MinClick = {} + ) + } + } + + compose.onNodeWithText(context.getString(R.string.common_btn_ok)).performClick() + + assertThat(primaryClicks).isEqualTo(1) + } + + @Test + fun render_muteButton_firesOnlyTheMuteCallback() { + var muteClicks = 0 + var mute5MinClicks = 0 + var primaryClicks = 0 + val model = buildAlarmUiModel(alarm(AlarmCause.ALARM_NOTICE_LOW_INSULIN, value = 20)) + + compose.setContent { + MaterialTheme { + CarelevoAlarmScreen( + alarm = model, + onPrimaryClick = { primaryClicks++ }, + onMuteClick = { muteClicks++ }, + onMute5MinClick = { mute5MinClicks++ } + ) + } + } + + compose.onNodeWithText(context.getString(CoreUiR.string.mute)).performClick() + + assertThat(muteClicks).isEqualTo(1) + assertThat(mute5MinClicks).isEqualTo(0) + assertThat(primaryClicks).isEqualTo(0) + } + + @Test + fun render_mute5MinButton_firesOnlyTheMute5MinCallback() { + var muteClicks = 0 + var mute5MinClicks = 0 + val model = buildAlarmUiModel(alarm(AlarmCause.ALARM_NOTICE_LOW_INSULIN, value = 20)) + + compose.setContent { + MaterialTheme { + CarelevoAlarmScreen( + alarm = model, + onPrimaryClick = {}, + onMuteClick = { muteClicks++ }, + onMute5MinClick = { mute5MinClicks++ } + ) + } + } + + compose.onNodeWithText(context.getString(CoreUiR.string.mute5min)).performClick() + + assertThat(mute5MinClicks).isEqualTo(1) + assertThat(muteClicks).isEqualTo(0) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/alarm/CarelevoAlarmScreenTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/alarm/CarelevoAlarmScreenTest.kt new file mode 100644 index 000000000000..532956dc97d0 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/alarm/CarelevoAlarmScreenTest.kt @@ -0,0 +1,329 @@ +package app.aaps.pump.carelevo.compose.alarm + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.click +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import app.aaps.pump.carelevo.R +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * UI test for [CarelevoAlarmScreen] - the blocking modal alarm surface presented by + * `CarelevoAlarmHost` while a Carelevo alarm is active. + * + * The screen is fully stateless (its only inputs are a nullable [CarelevoAlarmUiModel] and three + * lambdas), so no ViewModel double is needed: the model is passed directly and the callbacks are + * plain counters. + * + * Wrapped in [MaterialTheme] only - the screen reads `MaterialTheme.colorScheme` / `typography` but + * no `AapsTheme`-specific values, so it needs neither `LocalPreferences` nor a mocked `Preferences` + * (this mirrors the screen's own `@Preview`). + * + * A phone-sized qualifier is forced so the tall alarm card (icon + title + body + three stacked + * buttons) fits on screen and `assertIsDisplayed` / `performClick` stay meaningful. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35], qualifiers = "w411dp-h891dp-xhdpi") +class CarelevoAlarmScreenTest { + + @get:Rule + val compose = createComposeRule() + + private var primaryClicks = 0 + private var muteClicks = 0 + private var mute5MinClicks = 0 + + private fun alarmModel( + title: String = TITLE, + content: String = CONTENT + ) = CarelevoAlarmUiModel( + appIcon = R.drawable.ic_carelevo_128, + title = title, + content = content, + primaryButtonText = PRIMARY_BUTTON, + muteButtonText = MUTE_BUTTON, + mute5minButtonText = MUTE_5MIN_BUTTON + ) + + /** Hosts the screen alone, with the three callbacks wired to the click counters. */ + private fun setScreen(alarm: CarelevoAlarmUiModel?) { + compose.setContent { + MaterialTheme { + CarelevoAlarmScreen( + alarm = alarm, + onPrimaryClick = { primaryClicks++ }, + onMuteClick = { muteClicks++ }, + onMute5MinClick = { mute5MinClicks++ } + ) + } + } + } + + /** Hosts the screen stacked on top of a full-screen clickable underlay, to probe the scrim. */ + private fun setScreenOverUnderlay(alarm: CarelevoAlarmUiModel?, onUnderlayClick: () -> Unit) { + compose.setContent { + MaterialTheme { + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .testTag(UNDERLAY_TAG) + .clickable { onUnderlayClick() } + ) + CarelevoAlarmScreen( + alarm = alarm, + onPrimaryClick = { primaryClicks++ }, + onMuteClick = { muteClicks++ }, + onMute5MinClick = { mute5MinClicks++ } + ) + } + } + } + } + + // --------------------------------------------------------------------------------------------- + // Content branch + // --------------------------------------------------------------------------------------------- + + @Test + fun alarm_rendersTitleContentAndAllThreeButtons() { + setScreen(alarmModel()) + + compose.onNodeWithText(TITLE).assertIsDisplayed() + compose.onNodeWithText(CONTENT, substring = true).assertIsDisplayed() + compose.onNodeWithText(MUTE_5MIN_BUTTON).assertIsDisplayed() + compose.onNodeWithText(MUTE_BUTTON).assertIsDisplayed() + compose.onNodeWithText(PRIMARY_BUTTON).assertIsDisplayed() + } + + @Test + fun allThreeButtons_areEnabled() { + setScreen(alarmModel()) + + compose.onNodeWithText(MUTE_5MIN_BUTTON).assertIsEnabled() + compose.onNodeWithText(MUTE_BUTTON).assertIsEnabled() + compose.onNodeWithText(PRIMARY_BUTTON).assertIsEnabled() + } + + // --------------------------------------------------------------------------------------------- + // alarm == null early-return branch + // --------------------------------------------------------------------------------------------- + + @Test + fun nullAlarm_rendersNothing() { + setScreen(null) + + compose.onNodeWithText(TITLE).assertDoesNotExist() + compose.onNodeWithText(CONTENT, substring = true).assertDoesNotExist() + compose.onNodeWithText(MUTE_5MIN_BUTTON).assertDoesNotExist() + compose.onNodeWithText(MUTE_BUTTON).assertDoesNotExist() + compose.onNodeWithText(PRIMARY_BUTTON).assertDoesNotExist() + } + + @Test + fun alarmClearedToNull_removesCard() { + val alarmState = mutableStateOf(alarmModel()) + compose.setContent { + MaterialTheme { + CarelevoAlarmScreen( + alarm = alarmState.value, + onPrimaryClick = { primaryClicks++ }, + onMuteClick = { muteClicks++ }, + onMute5MinClick = { mute5MinClicks++ } + ) + } + } + compose.onNodeWithText(TITLE).assertIsDisplayed() + + compose.runOnUiThread { alarmState.value = null } + compose.waitForIdle() + + compose.onNodeWithText(TITLE).assertDoesNotExist() + compose.onNodeWithText(PRIMARY_BUTTON).assertDoesNotExist() + } + + @Test + fun alarmReplacedByNextAlarm_rendersNewModel() { + val alarmState = mutableStateOf(alarmModel(title = TITLE, content = CONTENT)) + compose.setContent { + MaterialTheme { + CarelevoAlarmScreen( + alarm = alarmState.value, + onPrimaryClick = { primaryClicks++ }, + onMuteClick = { muteClicks++ }, + onMute5MinClick = { mute5MinClicks++ } + ) + } + } + compose.onNodeWithText(TITLE).assertIsDisplayed() + + compose.runOnUiThread { + alarmState.value = alarmModel(title = SECOND_TITLE, content = SECOND_CONTENT) + } + compose.waitForIdle() + + compose.onNodeWithText(TITLE).assertDoesNotExist() + compose.onNodeWithText(SECOND_TITLE).assertIsDisplayed() + compose.onNodeWithText(SECOND_CONTENT, substring = true).assertIsDisplayed() + } + + // --------------------------------------------------------------------------------------------- + // content.isNotBlank() branch + // --------------------------------------------------------------------------------------------- + + @Test + fun emptyContent_hidesBodyText_butKeepsTitleAndButtons() { + setScreen(alarmModel(content = "")) + + compose.onNodeWithText(CONTENT, substring = true).assertDoesNotExist() + compose.onNodeWithText(TITLE).assertIsDisplayed() + compose.onNodeWithText(MUTE_5MIN_BUTTON).assertIsDisplayed() + compose.onNodeWithText(MUTE_BUTTON).assertIsDisplayed() + compose.onNodeWithText(PRIMARY_BUTTON).assertIsDisplayed() + } + + @Test + fun whitespaceOnlyContent_hidesBodyText() { + setScreen(alarmModel(content = " \n ")) + + compose.onNodeWithText(TITLE).assertIsDisplayed() + compose.onNodeWithText(PRIMARY_BUTTON).assertIsDisplayed() + } + + // --------------------------------------------------------------------------------------------- + // content HTML / newline handling + // --------------------------------------------------------------------------------------------- + + @Test + fun htmlContent_isRenderedAsFormattedText_withTagsStripped() { + setScreen(alarmModel(content = "Replace the patch now")) + + compose.onNodeWithText("Replace the patch now", substring = true).assertIsDisplayed() + compose.onNodeWithText("", substring = true).assertDoesNotExist() + compose.onNodeWithText("", substring = true).assertDoesNotExist() + } + + @Test + fun multilineContent_newlinesSurviveAsLineBreaks() { + setScreen(alarmModel(content = "First line\nSecond line")) + + // "\n" is converted to "
" before AnnotatedString.fromHtml, so both halves must land in + // the same body node. + compose.onNodeWithText("First line", substring = true).assertIsDisplayed() + compose.onNodeWithText("Second line", substring = true).assertIsDisplayed() + } + + // --------------------------------------------------------------------------------------------- + // Button callbacks + // --------------------------------------------------------------------------------------------- + + @Test + fun primaryButtonClick_invokesOnlyPrimaryCallback() { + setScreen(alarmModel()) + + compose.onNodeWithText(PRIMARY_BUTTON).performClick() + compose.waitForIdle() + + assertThat(primaryClicks).isEqualTo(1) + assertThat(muteClicks).isEqualTo(0) + assertThat(mute5MinClicks).isEqualTo(0) + } + + @Test + fun muteButtonClick_invokesOnlyMuteCallback() { + setScreen(alarmModel()) + + compose.onNodeWithText(MUTE_BUTTON).performClick() + compose.waitForIdle() + + assertThat(muteClicks).isEqualTo(1) + assertThat(primaryClicks).isEqualTo(0) + assertThat(mute5MinClicks).isEqualTo(0) + } + + @Test + fun mute5MinButtonClick_invokesOnlyMute5MinCallback() { + setScreen(alarmModel()) + + compose.onNodeWithText(MUTE_5MIN_BUTTON).performClick() + compose.waitForIdle() + + assertThat(mute5MinClicks).isEqualTo(1) + assertThat(primaryClicks).isEqualTo(0) + assertThat(muteClicks).isEqualTo(0) + } + + @Test + fun primaryButton_isNotLatched_andFiresOnEveryClick() { + setScreen(alarmModel()) + + compose.onNodeWithText(PRIMARY_BUTTON).performClick() + compose.onNodeWithText(PRIMARY_BUTTON).performClick() + compose.waitForIdle() + + assertThat(primaryClicks).isEqualTo(2) + } + + // --------------------------------------------------------------------------------------------- + // Blocking modal scrim + // --------------------------------------------------------------------------------------------- + + @Test + fun scrim_consumesTaps_soContentUnderneathIsNotReachable() { + var underlayClicks = 0 + setScreenOverUnderlay(alarmModel()) { underlayClicks++ } + + // Top-left corner: always outside the card (it is inset by AapsSpacing.xxLarge horizontally), + // so the tap lands on the scrim, which must swallow it. + compose.onRoot().performTouchInput { click(Offset(2f, 2f)) } + compose.waitForIdle() + + assertThat(underlayClicks).isEqualTo(0) + assertThat(primaryClicks).isEqualTo(0) + assertThat(muteClicks).isEqualTo(0) + assertThat(mute5MinClicks).isEqualTo(0) + } + + @Test + fun withoutAlarm_noScrim_soContentUnderneathStaysClickable() { + var underlayClicks = 0 + setScreenOverUnderlay(null) { underlayClicks++ } + + // Control for scrim_consumesTaps...: same tap, no alarm - it must reach the underlay. + compose.onRoot().performTouchInput { click(Offset(2f, 2f)) } + compose.waitForIdle() + + assertThat(underlayClicks).isEqualTo(1) + } + + private companion object { + + const val TITLE = "Low insulin warning" + const val CONTENT = "Insulin remaining is below threshold" + const val SECOND_TITLE = "Patch expired" + const val SECOND_CONTENT = "Replace the patch immediately" + const val PRIMARY_BUTTON = "Confirm" + const val MUTE_BUTTON = "Mute" + const val MUTE_5MIN_BUTTON = "Mute 5 min" + const val UNDERLAY_TAG = "underlay" + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/dialog/CarelevoActionDialogTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/dialog/CarelevoActionDialogTest.kt new file mode 100644 index 000000000000..969fdf70a699 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/dialog/CarelevoActionDialogTest.kt @@ -0,0 +1,349 @@ +package app.aaps.pump.carelevo.compose.dialog + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Compose UI test for [CarelevoActionDialog]. + * + * The dialog is fully state-hoisted (every input is a parameter), so no ViewModel is involved. + * It only reads `MaterialTheme.typography`, hence the plain `MaterialTheme` wrapper rather than + * `AapsTheme` (which additionally requires the `LocalPreferences` CompositionLocal). + * + * Covered branches: + * - `content.isNotBlank()` -> rendered / skipped + * - `content.contains('<') || content.contains("
")` -> HTML parsing vs plain AnnotatedString + * - the `"\n"` -> `"
"` replacement inside the HTML branch + * - `subContent.isNotBlank()` -> rendered / skipped (incl. the default `""`) + * - `secondaryText != null && onSecondaryClick != null` -> dismiss button rendered / skipped, + * including both half-supplied combinations + * - primary / secondary click callbacks fire, and fire only on their own button + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class CarelevoActionDialogTest { + + @get:Rule + val compose = createComposeRule() + + private val title = "Deactivate patch?" + private val primaryLabel = "Deactivate" + private val secondaryLabel = "Cancel" + + @Test + fun rendersTitleContentAndPrimaryButton() { + compose.setContent { + MaterialTheme { + CarelevoActionDialog( + title = title, + content = "Current patch will be deactivated.", + onDismissRequest = {}, + primaryText = primaryLabel, + onPrimaryClick = {} + ) + } + } + + compose.onNodeWithText(title).assertIsDisplayed() + compose.onNodeWithText("Current patch will be deactivated.").assertIsDisplayed() + compose.onNodeWithText(primaryLabel).assertIsDisplayed() + } + + @Test + fun rendersSecondaryButton_whenBothSecondaryTextAndCallbackProvided() { + compose.setContent { + MaterialTheme { + CarelevoActionDialog( + title = title, + content = "Body", + onDismissRequest = {}, + primaryText = primaryLabel, + onPrimaryClick = {}, + secondaryText = secondaryLabel, + onSecondaryClick = {} + ) + } + } + + compose.onNodeWithText(primaryLabel).assertIsDisplayed() + compose.onNodeWithText(secondaryLabel).assertIsDisplayed() + } + + @Test + fun hidesSecondaryButton_whenSecondaryTextAndCallbackOmitted() { + compose.setContent { + MaterialTheme { + CarelevoActionDialog( + title = title, + content = "Body", + onDismissRequest = {}, + primaryText = primaryLabel, + onPrimaryClick = {} + ) + } + } + + compose.onNodeWithText(primaryLabel).assertIsDisplayed() + compose.onNodeWithText(secondaryLabel).assertDoesNotExist() + } + + @Test + fun hidesSecondaryButton_whenCallbackMissing() { + compose.setContent { + MaterialTheme { + CarelevoActionDialog( + title = title, + content = "Body", + onDismissRequest = {}, + primaryText = primaryLabel, + onPrimaryClick = {}, + secondaryText = secondaryLabel, + onSecondaryClick = null + ) + } + } + + compose.onNodeWithText(secondaryLabel).assertDoesNotExist() + } + + @Test + fun hidesSecondaryButton_whenTextMissing() { + compose.setContent { + MaterialTheme { + CarelevoActionDialog( + title = title, + content = "Body", + onDismissRequest = {}, + primaryText = primaryLabel, + onPrimaryClick = {}, + secondaryText = null, + onSecondaryClick = {} + ) + } + } + + compose.onNodeWithText(secondaryLabel).assertDoesNotExist() + } + + @Test + fun hidesContent_whenContentIsBlank_butStillRendersSubContent() { + val blank = " " + compose.setContent { + MaterialTheme { + CarelevoActionDialog( + title = title, + content = blank, + onDismissRequest = {}, + primaryText = primaryLabel, + onPrimaryClick = {}, + subContent = "Sub only" + ) + } + } + + compose.onNodeWithText(title).assertIsDisplayed() + // A whitespace-only content must not be emitted as a Text node at all. + compose.onNodeWithText(blank).assertDoesNotExist() + compose.onNodeWithText("Sub only").assertIsDisplayed() + } + + @Test + fun rendersSubContent_whenProvided() { + compose.setContent { + MaterialTheme { + CarelevoActionDialog( + title = title, + content = "Main body", + onDismissRequest = {}, + primaryText = primaryLabel, + onPrimaryClick = {}, + subContent = "Extra details" + ) + } + } + + compose.onNodeWithText("Main body").assertIsDisplayed() + compose.onNodeWithText("Extra details").assertIsDisplayed() + } + + @Test + fun rendersContent_whenSubContentLeftAtDefaultEmpty() { + // Exercises the `subContent.isNotBlank()` false branch via the default argument: the dialog + // still renders its content and primary action, and emits no subContent Text. + compose.setContent { + MaterialTheme { + CarelevoActionDialog( + title = title, + content = "Main body", + onDismissRequest = {}, + primaryText = primaryLabel, + onPrimaryClick = {} + ) + } + } + + compose.onNodeWithText(title).assertIsDisplayed() + compose.onNodeWithText("Main body").assertIsDisplayed() + compose.onNodeWithText(primaryLabel).assertIsDisplayed() + } + + @Test + fun plainContent_withNewline_isRenderedVerbatim() { + // No '<' anywhere -> plain AnnotatedString branch, newline preserved as-is. + compose.setContent { + MaterialTheme { + CarelevoActionDialog( + title = title, + content = "Alpha\nBravo", + onDismissRequest = {}, + primaryText = primaryLabel, + onPrimaryClick = {} + ) + } + } + + compose.onNodeWithText("Alpha\nBravo").assertIsDisplayed() + } + + @Test + fun htmlContent_isParsed_andTagsAreStripped() { + compose.setContent { + MaterialTheme { + CarelevoActionDialog( + title = title, + content = "Bold warning", + onDismissRequest = {}, + primaryText = primaryLabel, + onPrimaryClick = {} + ) + } + } + + compose.onNodeWithText("Bold warning", substring = true).assertIsDisplayed() + // The raw markup must not survive into the rendered text. + compose.onNodeWithText("Bold warning").assertDoesNotExist() + } + + @Test + fun htmlContent_newlinesAreConvertedToLineBreaks() { + // Contains '<' -> HTML branch, so the "\n" is replaced by "
" before parsing. + compose.setContent { + MaterialTheme { + CarelevoActionDialog( + title = title, + content = "Alpha\nBravo", + onDismissRequest = {}, + primaryText = primaryLabel, + onPrimaryClick = {} + ) + } + } + + compose.onNodeWithText("Alpha", substring = true).assertIsDisplayed() + compose.onNodeWithText("Bravo", substring = true).assertIsDisplayed() + compose.onNodeWithText("Alpha", substring = true).assertDoesNotExist() + } + + @Test + fun contentWithOnlyBrTag_takesHtmlBranch() { + compose.setContent { + MaterialTheme { + CarelevoActionDialog( + title = title, + content = "First
Second", + onDismissRequest = {}, + primaryText = primaryLabel, + onPrimaryClick = {} + ) + } + } + + compose.onNodeWithText("First", substring = true).assertIsDisplayed() + compose.onNodeWithText("Second", substring = true).assertIsDisplayed() + compose.onNodeWithText("First
Second").assertDoesNotExist() + } + + @Test + fun primaryButton_firesOnPrimaryClickOnly() { + var primary = 0 + var secondary = 0 + var dismiss = 0 + compose.setContent { + MaterialTheme { + CarelevoActionDialog( + title = title, + content = "Body", + onDismissRequest = { dismiss++ }, + primaryText = primaryLabel, + onPrimaryClick = { primary++ }, + secondaryText = secondaryLabel, + onSecondaryClick = { secondary++ } + ) + } + } + + compose.onNodeWithText(primaryLabel).performClick() + + assertThat(primary).isEqualTo(1) + assertThat(secondary).isEqualTo(0) + assertThat(dismiss).isEqualTo(0) + } + + @Test + fun secondaryButton_firesOnSecondaryClickOnly() { + var primary = 0 + var secondary = 0 + var dismiss = 0 + compose.setContent { + MaterialTheme { + CarelevoActionDialog( + title = title, + content = "Body", + onDismissRequest = { dismiss++ }, + primaryText = primaryLabel, + onPrimaryClick = { primary++ }, + secondaryText = secondaryLabel, + onSecondaryClick = { secondary++ } + ) + } + } + + compose.onNodeWithText(secondaryLabel).performClick() + + assertThat(secondary).isEqualTo(1) + assertThat(primary).isEqualTo(0) + assertThat(dismiss).isEqualTo(0) + } + + @Test + fun primaryButton_isClickableRepeatedly() { + var primary = 0 + compose.setContent { + MaterialTheme { + CarelevoActionDialog( + title = title, + content = "Body", + onDismissRequest = {}, + primaryText = primaryLabel, + onPrimaryClick = { primary++ } + ) + } + } + + compose.onNodeWithText(primaryLabel).performClick() + compose.onNodeWithText(primaryLabel).performClick() + + assertThat(primary).isEqualTo(2) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/dialog/CarelevoPumpStopDurationDialogTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/dialog/CarelevoPumpStopDurationDialogTest.kt new file mode 100644 index 000000000000..42e752c78f3f --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/dialog/CarelevoPumpStopDurationDialogTest.kt @@ -0,0 +1,286 @@ +package app.aaps.pump.carelevo.compose.dialog + +import android.content.Context +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.isSelectable +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import app.aaps.pump.carelevo.R +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Compose UI test for [CarelevoPumpStopDurationDialog]. + * + * The dialog is state-hoisted apart from the internally remembered `selectedIndex`, so no ViewModel + * is involved. It reads only Material defaults, hence the plain `MaterialTheme` wrapper rather than + * `AapsTheme` (which additionally requires the `LocalPreferences` CompositionLocal). + * + * Labels/buttons are resolved from resources rather than hardcoded English so the test survives + * label and locale changes. + * + * Covered branches: + * - `labels.forEachIndexed` -> a radio row per label, and the empty-list case + * - `selectedIndex == index` -> selected / unselected radio state, seeded from `initialIndex` + * - `onClick { selectedIndex = index }` -> selection moves, including re-clicking the current one + * - confirm maps the selected index back through `options` (before and after a selection change) + * - cancel routes to `onDismissRequest` + * - `remember(initialIndex)` -> selection resets when the `initialIndex` key changes + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class CarelevoPumpStopDurationDialogTest { + + @get:Rule + val compose = createComposeRule() + + private val options = listOf(30, 60, 120) + private val labels = listOf("30 minutes", "1 hour", "2 hours") + + private lateinit var titleLabel: String + private lateinit var confirmLabel: String + private lateinit var cancelLabel: String + + @Before + fun setUp() { + val context: Context = RuntimeEnvironment.getApplication() + titleLabel = context.getString(R.string.carelevo_pump_stop_duration_title) + confirmLabel = context.getString(R.string.carelevo_btn_confirm) + cancelLabel = context.getString(R.string.carelevo_btn_cancel) + } + + private fun show( + initialIndex: Int = 0, + onDismissRequest: () -> Unit = {}, + onConfirm: (Int) -> Unit = {} + ) { + compose.setContent { + MaterialTheme { + CarelevoPumpStopDurationDialog( + options = options, + labels = labels, + initialIndex = initialIndex, + onDismissRequest = onDismissRequest, + onConfirm = onConfirm + ) + } + } + } + + @Test + fun rendersTitleAllLabelsAndBothButtons() { + show() + + compose.onNodeWithText(titleLabel).assertIsDisplayed() + labels.forEach { compose.onNodeWithText(it).assertIsDisplayed() } + compose.onNodeWithText(confirmLabel).assertIsDisplayed() + compose.onNodeWithText(cancelLabel).assertIsDisplayed() + } + + @Test + fun rendersOneRadioButtonPerLabel() { + show() + + compose.onAllNodes(isSelectable()).assertCountEquals(labels.size) + } + + @Test + fun initialIndex_preselectsMatchingRadioButton() { + show(initialIndex = 1) + + val radios = compose.onAllNodes(isSelectable()) + radios[0].assertIsNotSelected() + radios[1].assertIsSelected() + radios[2].assertIsNotSelected() + } + + @Test + fun firstOption_isPreselected_whenInitialIndexIsZero() { + show(initialIndex = 0) + + val radios = compose.onAllNodes(isSelectable()) + radios[0].assertIsSelected() + radios[1].assertIsNotSelected() + radios[2].assertIsNotSelected() + } + + @Test + fun lastOption_isPreselected_whenInitialIndexIsLast() { + show(initialIndex = 2) + + val radios = compose.onAllNodes(isSelectable()) + radios[0].assertIsNotSelected() + radios[1].assertIsNotSelected() + radios[2].assertIsSelected() + } + + @Test + fun confirm_withoutChangingSelection_returnsInitialOption() { + var confirmed: Int? = null + show(initialIndex = 1, onConfirm = { confirmed = it }) + + compose.onNodeWithText(confirmLabel).performClick() + + assertThat(confirmed).isEqualTo(60) + } + + @Test + fun selectingAnotherOption_movesSelection() { + show(initialIndex = 0) + + compose.onAllNodes(isSelectable())[2].performClick() + + val radios = compose.onAllNodes(isSelectable()) + radios[0].assertIsNotSelected() + radios[1].assertIsNotSelected() + radios[2].assertIsSelected() + } + + @Test + fun confirm_afterSelectionChange_returnsSelectedOption() { + var confirmed: Int? = null + show(initialIndex = 0, onConfirm = { confirmed = it }) + + compose.onAllNodes(isSelectable())[2].performClick() + compose.onNodeWithText(confirmLabel).performClick() + + assertThat(confirmed).isEqualTo(120) + } + + @Test + fun selectionCanMoveMultipleTimes_lastSelectionWins() { + var confirmed: Int? = null + show(initialIndex = 0, onConfirm = { confirmed = it }) + + compose.onAllNodes(isSelectable())[2].performClick() + compose.onAllNodes(isSelectable())[1].performClick() + compose.onNodeWithText(confirmLabel).performClick() + + assertThat(confirmed).isEqualTo(60) + } + + @Test + fun reclickingSelectedOption_keepsSelectionAndConfirmsSameOption() { + var confirmed: Int? = null + show(initialIndex = 1, onConfirm = { confirmed = it }) + + compose.onAllNodes(isSelectable())[1].performClick() + + compose.onAllNodes(isSelectable())[1].assertIsSelected() + compose.onNodeWithText(confirmLabel).performClick() + assertThat(confirmed).isEqualTo(60) + } + + @Test + fun confirm_doesNotFireDismiss() { + var dismissals = 0 + var confirmations = 0 + show(initialIndex = 0, onDismissRequest = { dismissals++ }, onConfirm = { confirmations++ }) + + compose.onNodeWithText(confirmLabel).performClick() + + assertThat(confirmations).isEqualTo(1) + assertThat(dismissals).isEqualTo(0) + } + + @Test + fun cancel_firesOnDismissRequestOnly() { + var dismissals = 0 + var confirmed: Int? = null + show(initialIndex = 0, onDismissRequest = { dismissals++ }, onConfirm = { confirmed = it }) + + compose.onNodeWithText(cancelLabel).performClick() + + assertThat(dismissals).isEqualTo(1) + assertThat(confirmed).isNull() + } + + @Test + fun changingInitialIndex_resetsSelection() { + // `remember(initialIndex)` is keyed, so a new initialIndex must discard the user's pick. + var initialIndex by mutableIntStateOf(0) + var confirmed: Int? = null + compose.setContent { + MaterialTheme { + CarelevoPumpStopDurationDialog( + options = options, + labels = labels, + initialIndex = initialIndex, + onDismissRequest = {}, + onConfirm = { confirmed = it } + ) + } + } + + compose.onAllNodes(isSelectable())[2].performClick() + compose.onAllNodes(isSelectable())[2].assertIsSelected() + + compose.runOnIdle { initialIndex = 1 } + + val radios = compose.onAllNodes(isSelectable()) + radios[1].assertIsSelected() + radios[2].assertIsNotSelected() + + compose.onNodeWithText(confirmLabel).performClick() + assertThat(confirmed).isEqualTo(60) + } + + @Test + fun emptyLabels_rendersTitleAndButtonsWithoutRadioButtons() { + compose.setContent { + MaterialTheme { + CarelevoPumpStopDurationDialog( + options = emptyList(), + labels = emptyList(), + initialIndex = 0, + onDismissRequest = {}, + onConfirm = {} + ) + } + } + + compose.onNodeWithText(titleLabel).assertIsDisplayed() + compose.onNodeWithText(confirmLabel).assertIsDisplayed() + compose.onNodeWithText(cancelLabel).assertIsDisplayed() + compose.onAllNodes(isSelectable()).assertCountEquals(0) + } + + @Test + fun singleOption_rendersAndConfirmsThatOption() { + var confirmed: Int? = null + compose.setContent { + MaterialTheme { + CarelevoPumpStopDurationDialog( + options = listOf(45), + labels = listOf("45 minutes"), + initialIndex = 0, + onDismissRequest = {}, + onConfirm = { confirmed = it } + ) + } + } + + compose.onNodeWithText("45 minutes").assertIsDisplayed() + compose.onAllNodes(isSelectable()).assertCountEquals(1) + compose.onAllNodes(isSelectable())[0].assertIsSelected() + + compose.onNodeWithText(confirmLabel).performClick() + assertThat(confirmed).isEqualTo(45) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowScreenTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowScreenTest.kt new file mode 100644 index 000000000000..7785b9bb4a80 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowScreenTest.kt @@ -0,0 +1,681 @@ +package app.aaps.pump.carelevo.compose.patchflow + +import android.content.Context +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import app.aaps.core.data.model.ICfg +import app.aaps.core.data.model.PS +import app.aaps.core.interfaces.db.PersistenceLayer +import app.aaps.core.interfaces.insulin.InsulinManager +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.profile.EffectiveProfile +import app.aaps.core.interfaces.profile.ProfileFunction +import app.aaps.core.interfaces.profile.ProfileRepository +import app.aaps.core.interfaces.profile.ProfileStore +import app.aaps.core.interfaces.profile.SingleProfile +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.core.interfaces.pump.ble.BleScanner +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.core.keys.BooleanKey +import app.aaps.core.keys.IntKey +import app.aaps.core.keys.StringKey +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.core.ui.R as CoreUiR +import app.aaps.core.ui.compose.AapsTheme +import app.aaps.core.ui.compose.LocalPreferences +import app.aaps.core.ui.compose.ToolbarConfig +import app.aaps.core.ui.compose.siteRotation.BodyType +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.ble.CarelevoBleSession +import app.aaps.pump.carelevo.ble.CarelevoBleTransport +import app.aaps.pump.carelevo.command.CarelevoActivationExecutor +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.common.model.PatchState +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import app.aaps.pump.carelevo.domain.usecase.alarm.CarelevoAlarmInfoUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoConnectNewPatchUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchForceDiscardUseCase +import app.aaps.pump.carelevo.presentation.model.CarelevoConnectEvent +import app.aaps.pump.carelevo.presentation.type.CarelevoPatchStep +import app.aaps.pump.carelevo.presentation.type.CarelevoScreenType +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchConnectViewModel +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchConnectionFlowViewModel +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchNeedleInsertionViewModel +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchSafetyCheckViewModel +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.plugins.RxJavaPlugins +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.BehaviorSubject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.stub +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import java.util.Optional + +/** + * Compose render tests for the activation wizard host [CarelevoPatchFlowScreen]: step routing, the + * exit/discard event plumbing and the Loading scrim. + * + * **How the four ViewModels are injected.** The screen resolves them with a bare `hiltViewModel()`, + * so there is no parameter to pass a double through. `hiltViewModel()` reads + * `LocalViewModelStoreOwner`, wraps that owner's default factory in + * `androidx.hilt.lifecycle.viewmodel.HiltViewModelFactory(context, delegate)` and hands the result to + * `viewModel()`. That wrapper walks up to the hosting Activity and pulls the Hilt + * `ActivityCreatorEntryPoint` off it — which blows up under the plain `ComponentActivity` that + * `createComposeRule()` launches, since it is not an `@AndroidEntryPoint`. [ShadowHiltViewModelFactory] + * replaces that one static wrapper with an identity function, so the delegate factory is used verbatim; + * [TestViewModelStoreOwner] then serves the real ViewModels (built here with mocked collaborators, the + * same way `CarelevoPatchConnectionFlowViewModelTest` does) straight out of its store. Only the + * `HiltViewModelFactory` class itself is instrumented — see `instrumentedPackages`, which is a + * `startsWith` match, not a package match. + * + * **Coroutines.** `viewModelScope` dispatches on `Dispatchers.Main`, replaced here by an + * [UnconfinedTestDispatcher] so a ViewModel call made from a test body has already settled by the time + * it returns. Composition keeps the rule's own dispatcher; `compose.waitForIdle()` bridges the two + * whenever an event has to travel from a ViewModel into the composition. + * + * **Rx.** [AapsSchedulers.io] / `main` are the trampoline so the force-discard chain runs inline. The + * Loading tests exploit that chain: `startPatchDiscardProcess` on a connected patch sets Loading, the + * queued `CmdDiscard` is stubbed to fail, and the force-discard fallback returns `Single.never()` — so + * the ViewModel parks in [app.aaps.pump.carelevo.common.model.UiState.Loading] and the scrim stays up. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config( + sdk = [35], + shadows = [ShadowHiltViewModelFactory::class], + instrumentedPackages = ["androidx.hilt.lifecycle.viewmodel.HiltViewModelFactory"] +) +class CarelevoPatchFlowScreenTest { + + @get:Rule + val compose = createComposeRule() + + private val testDispatcher = UnconfinedTestDispatcher() + + private lateinit var aapsLogger: AAPSLogger + private lateinit var aapsSchedulers: AapsSchedulers + private lateinit var carelevoPatch: CarelevoPatch + private lateinit var commandQueue: CommandQueue + private lateinit var patchForceDiscardUseCase: CarelevoPatchForceDiscardUseCase + private lateinit var preferences: Preferences + private lateinit var profileFunction: ProfileFunction + private lateinit var profileRepository: ProfileRepository + private lateinit var insulinManager: InsulinManager + private lateinit var persistenceLayer: PersistenceLayer + private lateinit var sp: SP + private lateinit var bleSession: CarelevoBleSession + private lateinit var transport: CarelevoBleTransport + private lateinit var scanner: BleScanner + private lateinit var connectNewPatchUseCase: CarelevoConnectNewPatchUseCase + private lateinit var pumpSync: PumpSync + private lateinit var carelevoAlarmInfoUseCase: CarelevoAlarmInfoUseCase + private lateinit var activationExecutor: CarelevoActivationExecutor + + private lateinit var patchStateSubject: BehaviorSubject> + private lateinit var patchInfoSubject: BehaviorSubject> + + private lateinit var flowViewModel: CarelevoPatchConnectionFlowViewModel + private lateinit var connectViewModel: CarelevoPatchConnectViewModel + private lateinit var needleViewModel: CarelevoPatchNeedleInsertionViewModel + private lateinit var safetyCheckViewModel: CarelevoPatchSafetyCheckViewModel + private lateinit var viewModelStoreOwner: TestViewModelStoreOwner + + private val snackbarHostState = SnackbarHostState() + private var toolbar: ToolbarConfig? = null + private var exitCount = 0 + + private val fiasp = ICfg(insulinLabel = "Fiasp", peak = 55, dia = 6.0, concentration = 1.0) + private val lyumjev = ICfg(insulinLabel = "Lyumjev", peak = 45, dia = 5.0, concentration = 1.0) + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(id: Int): String = context.getString(id) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + // The force-discard chain ends in subscribe(onSuccess) with no onError arm; swallow the + // undeliverable OnErrorNotImplementedException a late timeout would route to the plugin. + RxJavaPlugins.setErrorHandler { } + + aapsLogger = mock() + aapsSchedulers = mock() + carelevoPatch = mock() + commandQueue = mock() + patchForceDiscardUseCase = mock() + preferences = mock() + profileFunction = mock() + profileRepository = mock() + insulinManager = mock() + persistenceLayer = mock() + sp = mock() + bleSession = mock() + transport = mock() + scanner = mock() + connectNewPatchUseCase = mock() + pumpSync = mock() + carelevoAlarmInfoUseCase = mock() + activationExecutor = mock() + + whenever(aapsSchedulers.io).thenReturn(Schedulers.trampoline()) + whenever(aapsSchedulers.main).thenReturn(Schedulers.trampoline()) + + patchStateSubject = BehaviorSubject.create() + patchInfoSubject = BehaviorSubject.create() + whenever(carelevoPatch.patchState).thenReturn(patchStateSubject) + whenever(carelevoPatch.patchInfo).thenReturn(patchInfoSubject) + whenever(transport.scanner).thenReturn(scanner) + + // AapsTheme resolves the dark-mode preference; pin it so the colour scheme is deterministic. + whenever(preferences.observe(StringKey.GeneralDarkMode)).thenReturn(MutableStateFlow("light")) + whenever(preferences.get(BooleanKey.GeneralInsulinConcentration)).thenReturn(false) + whenever(preferences.get(BooleanKey.SiteRotationManagePump)).thenReturn(false) + whenever(preferences.get(IntKey.SiteRotationUserProfile)).thenReturn(BodyType.MAN.value) + whenever(profileRepository.profiles).thenReturn(MutableStateFlow(emptyList())) + + flowViewModel = CarelevoPatchConnectionFlowViewModel( + aapsLogger = aapsLogger, + aapsSchedulers = aapsSchedulers, + carelevoPatch = carelevoPatch, + commandQueue = commandQueue, + patchForceDiscardUseCase = patchForceDiscardUseCase, + preferences = preferences, + profileFunction = profileFunction, + profileRepository = profileRepository, + insulinManager = insulinManager, + persistenceLayer = persistenceLayer + ) + connectViewModel = CarelevoPatchConnectViewModel( + aapsLogger = aapsLogger, + aapsSchedulers = aapsSchedulers, + carelevoPatch = carelevoPatch, + commandQueue = commandQueue, + sp = sp, + bleSession = bleSession, + transport = transport, + connectNewPatchUseCase = connectNewPatchUseCase, + patchForceDiscardUseCase = patchForceDiscardUseCase + ) + needleViewModel = CarelevoPatchNeedleInsertionViewModel( + aapsLogger = aapsLogger, + pumpSync = pumpSync, + persistenceLayer = persistenceLayer, + aapsSchedulers = aapsSchedulers, + carelevoPatch = carelevoPatch, + commandQueue = commandQueue, + patchForceDiscardUseCase = patchForceDiscardUseCase, + carelevoAlarmInfoUseCase = carelevoAlarmInfoUseCase + ) + safetyCheckViewModel = CarelevoPatchSafetyCheckViewModel( + aapsSchedulers = aapsSchedulers, + aapsLogger = aapsLogger, + carelevoPatch = carelevoPatch, + commandQueue = commandQueue, + activationExecutor = activationExecutor, + patchForceDiscardUseCase = patchForceDiscardUseCase + ) + viewModelStoreOwner = TestViewModelStoreOwner( + mapOf( + CarelevoPatchConnectionFlowViewModel::class.java to flowViewModel, + CarelevoPatchConnectViewModel::class.java to connectViewModel, + CarelevoPatchNeedleInsertionViewModel::class.java to needleViewModel, + CarelevoPatchSafetyCheckViewModel::class.java to safetyCheckViewModel + ) + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + RxJavaPlugins.reset() + } + + // ---- harness ------------------------------------------------------------------------------ + + private fun setFlowContent(screenType: CarelevoScreenType = CarelevoScreenType.CONNECTION_FLOW_START) { + compose.setContent { + CompositionLocalProvider( + LocalPreferences provides preferences, + LocalViewModelStoreOwner provides viewModelStoreOwner + ) { + AapsTheme { + CarelevoPatchFlowScreen( + screenType = screenType, + setToolbarConfig = { toolbar = it }, + snackbarHostState = snackbarHostState, + onExitFlow = { exitCount++ } + ) + } + } + } + compose.waitForIdle() + } + + /** + * Park the wizard on [step] without letting the screen's init effect rebuild the workflow: the + * one-shot latch is what the screen checks, so pre-latching it pins the page the test asked for. + */ + private fun startAt(step: CarelevoPatchStep) { + flowViewModel.setIsCreated(true) + flowViewModel.setPage(step) + setFlowContent() + } + + /** Stub the collaborators `initWorkflow` fans out to. The `suspend` ones need a coroutine body. */ + private fun stubWorkflow( + needsProfileGate: Boolean = false, + siteRotation: Boolean = false, + insulins: List = listOf(fiasp), + activeLabel: String? = "Fiasp" + ) { + runBlocking { + val requested: PS? = if (needsProfileGate) null else mock() + whenever(profileFunction.getRequestedProfile()).thenReturn(requested) + whenever(preferences.get(BooleanKey.SiteRotationManagePump)).thenReturn(siteRotation) + whenever(insulinManager.insulins).thenReturn(ArrayList(insulins)) + val effective: EffectiveProfile? = + activeLabel?.let { label -> mock { on { iCfg } doReturn ICfg(label, 55, 6.0, 1.0) } } + whenever(profileFunction.getProfile()).thenReturn(effective) + whenever(persistenceLayer.getTherapyEventDataFromTime(any(), any())).thenReturn(emptyList()) + } + } + + private fun stubProfileList(names: List, originalName: String) { + runBlocking { + val profiles = names.map { profileName -> mock { on { name } doReturn profileName } } + whenever(profileRepository.profiles).thenReturn(MutableStateFlow(profiles)) + whenever(profileFunction.getOriginalProfileName()).thenReturn(originalName) + } + } + + /** Stub the queued command result. `customCommand` is `suspend`, so it stubs through `onBlocking`. */ + private fun stubCustomCommand(success: Boolean) { + val result = mock() + whenever(result.success).thenReturn(success) + commandQueue.stub { onBlocking { customCommand(any()) } doReturn result } + } + + /** + * Drive any of the four ViewModels into `UiState.Loading` and keep it there: the queued discard + * fails, and the DB-only fallback it routes to never emits. + */ + private fun armStuckDiscard() { + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + stubCustomCommand(success = false) + whenever(patchForceDiscardUseCase.execute()) + .thenReturn(Single.never>()) + } + + private fun assertLoadingScrimShown() { + compose.onNodeWithText(string(CoreUiR.string.loading)).assertIsDisplayed() + } + + // ---- step routing ------------------------------------------------------------------------- + + @Test + fun profileGateStep_rendersProfilePicker() { + stubWorkflow(needsProfileGate = true) + stubProfileList(names = listOf("Adult", "Night"), originalName = "Adult") + + setFlowContent() + + assertThat(toolbar?.title).isEqualTo(string(CoreUiR.string.pump_wizard_profile_gate_title)) + compose.onNodeWithText(string(CoreUiR.string.pump_wizard_profile_gate_pick)).assertIsDisplayed() + compose.onNodeWithText("Adult").assertIsDisplayed() + compose.onNodeWithText("Night").assertIsDisplayed() + compose.onNodeWithText(string(CoreUiR.string.activate_profile)).assertIsEnabled() + } + + @Test + fun profileGateStep_activateClick_createsProfileSwitchAndAdvances() { + stubWorkflow(needsProfileGate = true) + stubProfileList(names = listOf("Adult", "Night"), originalName = "Adult") + val store: ProfileStore = mock() + whenever(profileRepository.profile).thenReturn(MutableStateFlow(store)) + // Build the result eagerly: defining a mock inside an in-flight whenever() trips + // UnfinishedStubbingException. + val switched: PS = mock() + runBlocking { + whenever( + profileFunction.createProfileSwitch( + any(), any(), any(), any(), any(), any(), any(), any(), anyOrNull(), any(), any() + ) + ).thenReturn(switched) + } + setFlowContent() + + compose.onNodeWithText("Night").performClick() + compose.onNodeWithText(string(CoreUiR.string.activate_profile)).performClick() + compose.waitForIdle() + + verifyBlocking(profileFunction) { + createProfileSwitch(any(), eq("Night"), any(), any(), any(), any(), any(), any(), anyOrNull(), any(), any()) + } + assertThat(flowViewModel.page.value).isEqualTo(CarelevoPatchStep.PATCH_START) + } + + @Test + fun profileGateStep_cancelClick_exitsFlow() { + stubWorkflow(needsProfileGate = true) + stubProfileList(names = listOf("Adult"), originalName = "Adult") + setFlowContent() + + compose.onNodeWithText(string(CoreUiR.string.cancel)).performClick() + compose.waitForIdle() + + assertThat(exitCount).isEqualTo(1) + assertThat(flowViewModel.isCreated).isFalse() + } + + @Test + fun selectInsulinStep_rendersWhenMoreThanOneInsulinIsConfigured() { + stubWorkflow(insulins = listOf(fiasp, lyumjev), activeLabel = "Fiasp") + + setFlowContent() + + assertThat(toolbar?.title).isEqualTo(string(CoreUiR.string.select_insulin)) + assertThat(flowViewModel.page.value).isEqualTo(CarelevoPatchStep.SELECT_INSULIN) + compose.onNodeWithText("Lyumjev").assertIsDisplayed() + compose.onNodeWithText(string(CoreUiR.string.next)).assertIsEnabled() + } + + @Test + fun patchStartStep_rendersAndNextAdvancesToSetAmount() { + stubWorkflow(insulins = listOf(fiasp)) + + setFlowContent() + + assertThat(toolbar?.title).isEqualTo(string(R.string.carelevo_connect_prepare_title)) + compose.onNodeWithText(string(R.string.carelevo_title_fill_insulin)).assertIsDisplayed() + compose.onNodeWithText(string(R.string.carelevo_btn_insulin_guide)).assertIsDisplayed() + + compose.onNodeWithText(string(CoreUiR.string.next)).performClick() + compose.waitForIdle() + + assertThat(flowViewModel.page.value).isEqualTo(CarelevoPatchStep.SET_AMOUNT) + assertThat(toolbar?.title).isEqualTo(string(R.string.patch_prepare_dialog_title_insulin_amount)) + } + + @Test + fun patchStartStep_cancelClick_exitsFlowAndClearsCreatedLatch() { + stubWorkflow(insulins = listOf(fiasp)) + setFlowContent() + assertThat(flowViewModel.isCreated).isTrue() + + compose.onNodeWithText(string(CoreUiR.string.cancel)).performClick() + compose.waitForIdle() + + assertThat(exitCount).isEqualTo(1) + // Without the latch reset a second activation would resume mid-flow against a fresh patch. + assertThat(flowViewModel.isCreated).isFalse() + } + + @Test + fun setAmountStep_renders() { + startAt(CarelevoPatchStep.SET_AMOUNT) + + assertThat(toolbar?.title).isEqualTo(string(R.string.patch_prepare_dialog_title_insulin_amount)) + compose.onNodeWithText(string(CoreUiR.string.next)).assertIsEnabled() + compose.onNodeWithText(string(CoreUiR.string.cancel)).assertIsEnabled() + } + + @Test + fun patchConnectStep_rendersAndResetsTheConnectViewModelOnEnter() { + startAt(CarelevoPatchStep.PATCH_CONNECT) + + assertThat(toolbar?.title).isEqualTo(string(R.string.carelevo_connect_patch_title)) + compose.onNodeWithText(string(R.string.carelevo_patch_connect_step_1_title)).assertIsDisplayed() + compose.onNodeWithText(string(R.string.carelevo_btn_input_search_patch)).assertIsEnabled() + // The page-entry effect must clear any scan left running by a previous visit. + verify(scanner).stopScan() + assertThat(connectViewModel.isScanWorking).isFalse() + } + + @Test + fun safetyCheckStep_rendersFromTheSafetyCheckEntryPoint() { + stubWorkflow() + + setFlowContent(CarelevoScreenType.SAFETY_CHECK) + + assertThat(flowViewModel.page.value).isEqualTo(CarelevoPatchStep.SAFETY_CHECK) + assertThat(toolbar?.title).isEqualTo(string(R.string.carelevo_connect_safety_check_title)) + compose.onNodeWithText(string(R.string.carelevo_patch_safety_check_start_desc)).assertIsDisplayed() + compose.onNodeWithText(string(R.string.carelevo_btn_patch_expiration)).assertIsEnabled() + } + + @Test + fun siteLocationStep_renders() { + startAt(CarelevoPatchStep.SITE_LOCATION) + + assertThat(toolbar?.title).isEqualTo(string(CoreUiR.string.site_rotation)) + compose.onNodeWithText(string(CoreUiR.string.skip)).assertIsEnabled() + // No location picked yet, so the step cannot be completed. + compose.onNodeWithText(string(CoreUiR.string.next)).assertIsNotEnabled() + } + + @Test + fun patchAttachStep_rendersAndNextAdvancesToNeedleInsertion() { + startAt(CarelevoPatchStep.PATCH_ATTACH) + + assertThat(toolbar?.title).isEqualTo(string(R.string.carelevo_connect_patch_attach_title)) + compose.onNodeWithText(string(R.string.carelevo_patch_attach_step1_title)).assertIsDisplayed() + + compose.onNodeWithText(string(CoreUiR.string.next)).performClick() + compose.waitForIdle() + + assertThat(flowViewModel.page.value).isEqualTo(CarelevoPatchStep.NEEDLE_INSERTION) + assertThat(toolbar?.title).isEqualTo(string(R.string.carelevo_connect_needle_check_title)) + } + + @Test + fun needleInsertionStep_rendersAndObservesPatchInfo() { + startAt(CarelevoPatchStep.NEEDLE_INSERTION) + + assertThat(toolbar?.title).isEqualTo(string(R.string.carelevo_connect_needle_check_title)) + compose.onNodeWithText(string(R.string.carelevo_patch_needle_insertion_step1_title)).assertIsDisplayed() + compose.onNodeWithText(string(R.string.carelevo_btn_needle_insert_check)).assertIsEnabled() + assertThat(needleViewModel.isCreated).isTrue() + assertThat(patchInfoSubject.hasObservers()).isTrue() + } + + // ---- loading scrim ------------------------------------------------------------------------ + + @Test + fun loadingScrim_isHiddenWhileEveryViewModelIsIdle() { + stubWorkflow(insulins = listOf(fiasp)) + + setFlowContent() + + compose.onNodeWithText(string(CoreUiR.string.loading)).assertDoesNotExist() + } + + @Test + fun loadingScrim_isShownForTheSharedViewModelOutsideTheDedicatedSteps() { + startAt(CarelevoPatchStep.PATCH_START) + armStuckDiscard() + + flowViewModel.startPatchDiscardProcess() + compose.waitForIdle() + + assertLoadingScrimShown() + } + + @Test + fun loadingScrim_isShownForTheConnectViewModelOnTheConnectStep() { + startAt(CarelevoPatchStep.PATCH_CONNECT) + armStuckDiscard() + + connectViewModel.startPatchDiscardProcess() + compose.waitForIdle() + + assertLoadingScrimShown() + } + + @Test + fun loadingScrim_isShownForTheSafetyCheckViewModelOnTheSafetyCheckStep() { + startAt(CarelevoPatchStep.SAFETY_CHECK) + armStuckDiscard() + + safetyCheckViewModel.startDiscardProcess() + compose.waitForIdle() + + assertLoadingScrimShown() + } + + @Test + fun loadingScrim_isShownForTheNeedleViewModelOnTheNeedleStep() { + startAt(CarelevoPatchStep.NEEDLE_INSERTION) + armStuckDiscard() + + needleViewModel.startDiscardProcess() + compose.waitForIdle() + + assertLoadingScrimShown() + } + + @Test + fun loadingScrim_ignoresANonActiveViewModelsLoadingState() { + // The connect ViewModel is loading, but the wizard is parked on a step it does not own. + startAt(CarelevoPatchStep.PATCH_ATTACH) + armStuckDiscard() + + connectViewModel.startPatchDiscardProcess() + compose.waitForIdle() + + compose.onNodeWithText(string(CoreUiR.string.loading)).assertDoesNotExist() + } + + // ---- exit / discard events ---------------------------------------------------------------- + + @Test + fun discardCompleteEvent_exitsTheFlowAndClearsTheCreatedLatch() { + startAt(CarelevoPatchStep.PATCH_START) + + // No patch state at all -> the discard short-circuits straight to DiscardComplete. + flowViewModel.startPatchDiscardProcess() + compose.waitForIdle() + + assertThat(exitCount).isEqualTo(1) + assertThat(flowViewModel.isCreated).isFalse() + verifyBlocking(commandQueue, never()) { customCommand(any()) } + } + + @Test + fun exitFlowEvent_exitsTheFlow() { + startAt(CarelevoPatchStep.PATCH_START) + + flowViewModel.exitWizard() + compose.waitForIdle() + + assertThat(exitCount).isEqualTo(1) + assertThat(flowViewModel.isCreated).isFalse() + } + + @Test + fun discardFailedEvent_surfacesTheFailureSnackbarWithoutExiting() { + startAt(CarelevoPatchStep.PATCH_START) + + flowViewModel.triggerEvent(CarelevoConnectEvent.DiscardFailed) + compose.waitForIdle() + + assertThat(snackbarHostState.currentSnackbarData?.visuals?.message) + .isEqualTo(string(R.string.carelevo_toast_msg_discard_failed)) + assertThat(exitCount).isEqualTo(0) + } + + @Test + fun unmappedEvent_isIgnored() { + startAt(CarelevoPatchStep.PATCH_START) + + flowViewModel.triggerEvent(CarelevoConnectEvent.NoAction) + compose.waitForIdle() + + assertThat(exitCount).isEqualTo(0) + assertThat(snackbarHostState.currentSnackbarData).isNull() + assertThat(flowViewModel.isCreated).isTrue() + } +} + +/** + * Test double registry for `hiltViewModel()`. Implementing [HasDefaultViewModelProviderFactory] is what + * makes `hiltViewModel()` pick this factory up as its delegate; [ShadowHiltViewModelFactory] then keeps + * that delegate intact instead of wrapping it in Hilt's activity-bound factory. + */ +private class TestViewModelStoreOwner( + private val viewModels: Map, ViewModel> +) : ViewModelStoreOwner, HasDefaultViewModelProviderFactory { + + override val viewModelStore: ViewModelStore = ViewModelStore() + + override val defaultViewModelProviderFactory: ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class, extras: CreationExtras): T = + viewModels[modelClass] as? T ?: error("No test double registered for $modelClass") + } +} + +/** + * Neutralises `androidx.hilt.lifecycle.viewmodel.HiltViewModelFactory(context, delegateFactory)` — the + * static wrapper `hiltViewModel()` always routes through. The real one resolves Hilt's + * `ActivityCreatorEntryPoint` off the hosting Activity, which only exists on an `@AndroidEntryPoint` + * Activity; the test host is a plain `ComponentActivity`. Returning the delegate verbatim is exactly + * what the real factory does for any ViewModel outside `@HiltViewModelMap`, so the production lookup + * path (`viewModel(owner, key, factory)` -> the owner's store) is preserved. + */ +@Implements(className = "androidx.hilt.lifecycle.viewmodel.HiltViewModelFactory", isInAndroidSdk = false) +class ShadowHiltViewModelFactory { + + companion object { + + @JvmStatic + @Implementation + fun create(context: Context, delegateFactory: ViewModelProvider.Factory): ViewModelProvider.Factory = + delegateFactory + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep01StartTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep01StartTest.kt new file mode 100644 index 000000000000..d8fe745157f1 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep01StartTest.kt @@ -0,0 +1,269 @@ +package app.aaps.pump.carelevo.compose.patchflow + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import app.aaps.core.ui.R as CoreUiR +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.config.FillConfig +import app.aaps.pump.carelevo.presentation.type.CarelevoPatchStep +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchConnectionFlowViewModel +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * UI test for [CarelevoPatchFlowStep01Start] — the first step of the patch activation wizard — and for + * [patchStepTitle], the shared title mapper every step of that wizard is labelled with. + * + * **ViewModel:** the step takes a real [CarelevoPatchConnectionFlowViewModel] built from all-mocked + * collaborators rather than a mock of the ViewModel itself. Construction touches none of the ten + * dependencies (every field is a plain initialiser) and the only member the step calls — + * `setPage` — is pure state (`tryEmit` + a `workflowSteps.indexOf`), so the real object works and lets + * the Next click be asserted against observable state (`page.value`) instead of a mock interaction. + * No `Dispatchers.setMain` is needed: nothing here reaches `viewModelScope`. + * + * **Theme:** [MaterialTheme] only — the step and `WizardStepLayout` read `MaterialTheme.typography` / + * `colorScheme` but no `AapsTheme` values, so no `LocalPreferences` is required (this mirrors the + * step's own `@Preview`). + * + * Labels are read back through `getString` so the assertions track the resources, not hardcoded + * English. A phone-sized qualifier is forced so the pinned action row and the collapsed content fit on + * screen; the twelve expanded refill steps are far taller than any display, so those are asserted with + * `assertExists` (the guide is a plain scrolling `Column`, so every entry is composed) rather than + * `assertIsDisplayed`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35], qualifiers = "w411dp-h891dp-xhdpi") +class CarelevoPatchFlowStep01StartTest { + + @get:Rule + val compose = createComposeRule() + + private lateinit var viewModel: CarelevoPatchConnectionFlowViewModel + private var exitFlowCalls = 0 + + private lateinit var title: String + private lateinit var notice: String + private lateinit var guideLabel: String + private lateinit var nextLabel: String + private lateinit var cancelLabel: String + private lateinit var refillSteps: List + + @Before + fun setUp() { + val app = RuntimeEnvironment.getApplication() + title = app.getString(R.string.carelevo_title_fill_insulin) + notice = app.getString(R.string.carelevo_notice_fill_insulin_amount, FillConfig.FILL_MIN_UNITS, FillConfig.FILL_MAX_UNITS) + guideLabel = app.getString(R.string.carelevo_btn_insulin_guide) + nextLabel = app.getString(CoreUiR.string.next) + cancelLabel = app.getString(CoreUiR.string.cancel) + refillSteps = listOf( + app.getString(R.string.carelevo_insulin_refill_step1), + app.getString(R.string.carelevo_insulin_refill_step2), + app.getString(R.string.carelevo_insulin_refill_step3), + app.getString(R.string.carelevo_insulin_refill_step4), + app.getString(R.string.carelevo_insulin_refill_step5), + app.getString(R.string.carelevo_insulin_refill_step6), + app.getString(R.string.carelevo_insulin_refill_step7), + app.getString(R.string.carelevo_insulin_refill_step8), + app.getString(R.string.carelevo_insulin_refill_step9), + app.getString(R.string.carelevo_insulin_refill_step10), + app.getString(R.string.carelevo_insulin_refill_step11), + app.getString(R.string.carelevo_insulin_refill_step12) + ) + + exitFlowCalls = 0 + viewModel = CarelevoPatchConnectionFlowViewModel( + aapsLogger = mock(), + aapsSchedulers = mock(), + carelevoPatch = mock(), + commandQueue = mock(), + patchForceDiscardUseCase = mock(), + preferences = mock(), + profileFunction = mock(), + profileRepository = mock(), + insulinManager = mock(), + persistenceLayer = mock() + ) + } + + /** Hosts the step under test with the exit callback wired to a counter. */ + private fun setStep() { + compose.setContent { + MaterialTheme { + CarelevoPatchFlowStep01Start( + viewModel = viewModel, + onExitFlow = { exitFlowCalls++ } + ) + } + } + } + + /** The collapsed guide's trigger: the only node carrying the guide label *and* a click action. */ + private fun guideButton() = compose.onNode(hasText(guideLabel) and hasClickAction()) + + @Test + fun initialState_showsTitleNoticeAndBothActions() { + setStep() + + compose.onNodeWithText(title).assertIsDisplayed() + compose.onNodeWithText(notice).assertIsDisplayed() + compose.onNodeWithText(nextLabel).assertIsDisplayed().assertIsEnabled() + compose.onNodeWithText(cancelLabel).assertIsDisplayed().assertIsEnabled() + } + + @Test + fun initialState_guideIsCollapsed_showingOnlyItsButton() { + setStep() + + guideButton().assertIsDisplayed().assertIsEnabled() + // Collapsed: the label exists exactly once (the button), and no refill step is composed. + compose.onAllNodesWithText(guideLabel).assertCountEquals(1) + refillSteps.forEach { step -> + compose.onNodeWithText(step).assertDoesNotExist() + } + } + + @Test + fun initialState_viewModelStaysOnPatchStart() { + setStep() + + assertThat(viewModel.page.value).isEqualTo(CarelevoPatchStep.PATCH_START) + assertThat(exitFlowCalls).isEqualTo(0) + } + + @Test + fun nextButton_advancesViewModelToSetAmount_withoutExitingFlow() { + setStep() + + compose.onNodeWithText(nextLabel).performClick() + + assertThat(viewModel.page.value).isEqualTo(CarelevoPatchStep.SET_AMOUNT) + assertThat(exitFlowCalls).isEqualTo(0) + } + + @Test + fun cancelButton_invokesOnExitFlow_andLeavesThePageUnchanged() { + setStep() + + compose.onNodeWithText(cancelLabel).performClick() + + assertThat(exitFlowCalls).isEqualTo(1) + assertThat(viewModel.page.value).isEqualTo(CarelevoPatchStep.PATCH_START) + } + + @Test + fun guideButton_whenClicked_expandsAndRendersAllTwelveRefillSteps() { + setStep() + + guideButton().performClick() + + refillSteps.forEach { step -> + compose.onNodeWithText(step).assertExists() + } + } + + @Test + fun guideButton_whenExpanded_isReplacedByAPlainHeading() { + setStep() + + guideButton().performClick() + + // The label survives as the section heading, but the clickable trigger is gone. + guideButton().assertDoesNotExist() + compose.onAllNodesWithText(guideLabel).assertCountEquals(1) + compose.onNodeWithText(guideLabel).assertExists() + } + + @Test + fun guideExpansion_doesNotDisturbTheStepContentOrActions() { + setStep() + + guideButton().performClick() + + compose.onNodeWithText(title).assertExists() + compose.onNodeWithText(notice).assertExists() + compose.onNodeWithText(nextLabel).assertIsDisplayed().assertIsEnabled() + compose.onNodeWithText(cancelLabel).assertIsDisplayed().assertIsEnabled() + } + + @Test + fun expandedGuide_survivesRecomposition_andNextStillAdvances() { + setStep() + + guideButton().performClick() + compose.onNodeWithText(nextLabel).performClick() + + assertThat(viewModel.page.value).isEqualTo(CarelevoPatchStep.SET_AMOUNT) + // The remembered expansion is unaffected by the click that recomposed the step. + compose.onNodeWithText(refillSteps.first()).assertExists() + guideButton().assertDoesNotExist() + } + + @Test + fun cancelButton_whenClickedTwice_reportsEveryExitRequest() { + setStep() + + compose.onNodeWithText(cancelLabel).performClick() + compose.onNodeWithText(cancelLabel).performClick() + + assertThat(exitFlowCalls).isEqualTo(2) + } + + @Test + fun patchStepTitle_labelsEveryWizardStep() { + val app = RuntimeEnvironment.getApplication() + val expected = mapOf( + CarelevoPatchStep.PROFILE_GATE to app.getString(CoreUiR.string.pump_wizard_profile_gate_title), + CarelevoPatchStep.SELECT_INSULIN to app.getString(CoreUiR.string.select_insulin), + CarelevoPatchStep.PATCH_START to app.getString(R.string.carelevo_connect_prepare_title), + CarelevoPatchStep.SET_AMOUNT to app.getString(R.string.patch_prepare_dialog_title_insulin_amount), + CarelevoPatchStep.PATCH_CONNECT to app.getString(R.string.carelevo_connect_patch_title), + CarelevoPatchStep.SAFETY_CHECK to app.getString(R.string.carelevo_connect_safety_check_title), + CarelevoPatchStep.SITE_LOCATION to app.getString(CoreUiR.string.site_rotation), + CarelevoPatchStep.PATCH_ATTACH to app.getString(R.string.carelevo_connect_patch_attach_title), + CarelevoPatchStep.NEEDLE_INSERTION to app.getString(R.string.carelevo_connect_needle_check_title) + ) + + compose.setContent { + MaterialTheme { + Column { + CarelevoPatchStep.entries.forEach { step -> + Text( + text = patchStepTitle(step), + modifier = Modifier.testTag(step.name) + ) + } + } + } + } + + // Guards the `when` against a new enum constant being added without a title. + assertThat(expected.keys).containsExactlyElementsIn(CarelevoPatchStep.entries) + expected.forEach { (step, label) -> + compose.onNodeWithTag(step.name).assertTextEquals(label) + } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep02ConnectTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep02ConnectTest.kt new file mode 100644 index 000000000000..245520dc20ad --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep02ConnectTest.kt @@ -0,0 +1,429 @@ +package app.aaps.pump.carelevo.compose.patchflow + +import android.content.Context +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.common.MutableEventFlow +import app.aaps.pump.carelevo.common.model.Event +import app.aaps.pump.carelevo.presentation.model.CarelevoConnectPrepareEvent +import app.aaps.pump.carelevo.presentation.type.CarelevoPatchStep +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchConnectViewModel +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchConnectionFlowViewModel +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Compose UI test for [CarelevoPatchFlowStep02Connect] — the activation wizard's scan/connect step. + * + * **Injection strategy:** both ViewModels are Mockito mocks (Mockito 5's inline mock-maker handles the + * final Kotlin classes, exactly as `CarelevoPatchConnectViewModelTest` in this module already relies on). + * The composable only *reads* `viewModel.event` and `sharedViewModel.inputInsulin` and *calls* command + * methods, so a mock covers the whole contract: + * - `viewModel.event` is stubbed with a **real** [MutableEventFlow] (`replay = 8`, so `emit` from the + * test thread never suspends waiting on the Compose-side collector), which lets every branch of the + * screen's `LaunchedEffect` event `when` be driven deterministically, + * - every command (`startScan`, `startConnect`, `startPatchDiscardProcess`) and the shared VM's + * `setPage` are asserted with `verify`. + * + * Labels come from resources rather than hardcoded English so the test survives label/locale changes. + * + * **Not covered:** the two dialogs' `onDismissRequest` lambdas (back-press / scrim tap) — Compose's test + * API cannot dispatch a dialog dismissal, and both merely flip the same local flag the Cancel/Rescan + * paths already exercise. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class CarelevoPatchFlowStep02ConnectTest { + + @get:Rule + val compose = createComposeRule() + + private val viewModel: CarelevoPatchConnectViewModel = mock() + private val sharedViewModel: CarelevoPatchConnectionFlowViewModel = mock() + + /** Replay of 8 keeps [emitEvent] non-suspending; consumed slots are skipped by `EventFlowSlot`. */ + private val events = MutableEventFlow(replay = 8) + + private var exitFlowCount = 0 + + private lateinit var searchLabel: String + private lateinit var deactivateLabel: String + private lateinit var confirmLabel: String + private lateinit var cancelLabel: String + private lateinit var rescanLabel: String + private lateinit var step1Label: String + private lateinit var step2Label: String + private lateinit var step1Title: String + private lateinit var step1Desc: String + private lateinit var step2Title: String + private lateinit var step2Desc: String + private lateinit var discardDialogTitle: String + private lateinit var discardDialogDesc: String + private lateinit var connectDialogTitle: String + private lateinit var scanFailedMsg: String + private lateinit var bluetoothNotEnabledMsg: String + private lateinit var profileNotSetMsg: String + private lateinit var patchNotFoundMsg: String + private lateinit var connectFailedMsg: String + private lateinit var discardFailedMsg: String + + @Before + fun setUp() { + val context: Context = RuntimeEnvironment.getApplication() + searchLabel = context.getString(R.string.carelevo_btn_input_search_patch) + deactivateLabel = context.getString(R.string.carelevo_btn_patch_expiration) + confirmLabel = context.getString(R.string.carelevo_btn_confirm) + cancelLabel = context.getString(R.string.carelevo_btn_cancel) + rescanLabel = context.getString(R.string.carelevo_btn_research) + step1Label = context.getString(R.string.carelevo_patch_step_1) + step2Label = context.getString(R.string.carelevo_patch_step_2) + step1Title = context.getString(R.string.carelevo_patch_connect_step_1_title) + step1Desc = context.getString(R.string.carelevo_patch_connect_step_1_desc) + step2Title = context.getString(R.string.carelevo_patch_connect_step_2_title) + step2Desc = context.getString(R.string.carelevo_patch_connect_step_2_desc) + discardDialogTitle = context.getString(R.string.carelevo_dialog_patch_discard_message_title) + discardDialogDesc = context.getString(R.string.carelevo_dialog_patch_discard_message_desc) + connectDialogTitle = context.getString(R.string.carelevo_dialog_patch_connect_message_title) + scanFailedMsg = context.getString(R.string.carelevo_toast_msg_scan_failed) + bluetoothNotEnabledMsg = context.getString(R.string.carelevo_toast_msg_bluetooth_not_enabled) + profileNotSetMsg = context.getString(R.string.carelevo_toast_msg_profile_not_set) + patchNotFoundMsg = context.getString(R.string.carelevo_toast_msg_patch_not_found) + connectFailedMsg = context.getString(R.string.carelevo_toast_msg_connect_failed) + discardFailedMsg = context.getString(R.string.carelevo_toast_msg_discard_failed) + + whenever(viewModel.event).thenReturn(events) + whenever(sharedViewModel.inputInsulin).thenReturn(INPUT_INSULIN) + } + + private fun setContent() { + compose.setContent { + MaterialTheme { + CarelevoPatchFlowStep02Connect( + viewModel = viewModel, + sharedViewModel = sharedViewModel, + onExitFlow = { exitFlowCount++ } + ) + } + } + } + + /** Push one event through the screen's collector and let the resulting recomposition settle. */ + private fun emitEvent(event: Event) { + runBlocking { events.emit(event) } + compose.waitForIdle() + } + + private fun assertNoErrorBanner() { + compose.onNodeWithText(scanFailedMsg).assertDoesNotExist() + compose.onNodeWithText(bluetoothNotEnabledMsg).assertDoesNotExist() + compose.onNodeWithText(profileNotSetMsg).assertDoesNotExist() + compose.onNodeWithText(patchNotFoundMsg).assertDoesNotExist() + compose.onNodeWithText(connectFailedMsg).assertDoesNotExist() + compose.onNodeWithText(discardFailedMsg).assertDoesNotExist() + } + + // region content + + @Test + fun rendersBothStepSections_andNoErrorBanner_initially() { + setContent() + + compose.onNodeWithText(step1Label).assertIsDisplayed() + compose.onNodeWithText(step1Title).assertIsDisplayed() + compose.onNodeWithText(step1Desc).assertIsDisplayed() + compose.onNodeWithText(step2Label).assertIsDisplayed() + compose.onNodeWithText(step2Title).assertIsDisplayed() + compose.onNodeWithText(step2Desc).assertIsDisplayed() + assertNoErrorBanner() + } + + @Test + fun rendersBothWizardButtons_enabled_andNoDialog() { + setContent() + + compose.onNodeWithText(searchLabel).assertIsDisplayed().assertIsEnabled() + compose.onNodeWithText(deactivateLabel).assertIsDisplayed().assertIsEnabled() + compose.onNodeWithText(discardDialogTitle).assertDoesNotExist() + compose.onNodeWithText(connectDialogTitle).assertDoesNotExist() + } + + // endregion + + // region primary / secondary buttons + + @Test + fun searchButton_click_callsStartScan() { + setContent() + + compose.onNodeWithText(searchLabel).performClick() + compose.waitForIdle() + + verify(viewModel).startScan() + verify(viewModel, never()).startPatchDiscardProcess() + } + + @Test + fun searchButton_click_clearsPreviousErrorBanner() { + setContent() + emitEvent(CarelevoConnectPrepareEvent.ShowMessageScanFailed) + compose.onNodeWithText(scanFailedMsg).assertIsDisplayed() + + compose.onNodeWithText(searchLabel).performClick() + compose.waitForIdle() + + compose.onNodeWithText(scanFailedMsg).assertDoesNotExist() + verify(viewModel).startScan() + } + + @Test + fun deactivateButton_click_showsDiscardDialog_withoutCallingViewModel() { + setContent() + + compose.onNodeWithText(deactivateLabel).performClick() + compose.waitForIdle() + + compose.onNodeWithText(discardDialogTitle).assertIsDisplayed() + compose.onNodeWithText(discardDialogDesc).assertIsDisplayed() + compose.onNodeWithText(confirmLabel).assertIsDisplayed() + compose.onNodeWithText(cancelLabel).assertIsDisplayed() + verify(viewModel, never()).startPatchDiscardProcess() + } + + // endregion + + // region discard dialog + + @Test + fun discardDialog_confirm_callsStartPatchDiscardProcess_andDismisses() { + setContent() + compose.onNodeWithText(deactivateLabel).performClick() + compose.waitForIdle() + + compose.onNodeWithText(confirmLabel).performClick() + compose.waitForIdle() + + verify(viewModel).startPatchDiscardProcess() + compose.onNodeWithText(discardDialogTitle).assertDoesNotExist() + assertThat(exitFlowCount).isEqualTo(0) + } + + @Test + fun discardDialog_cancel_dismisses_withoutCallingViewModel() { + setContent() + compose.onNodeWithText(deactivateLabel).performClick() + compose.waitForIdle() + + compose.onNodeWithText(cancelLabel).performClick() + compose.waitForIdle() + + compose.onNodeWithText(discardDialogTitle).assertDoesNotExist() + compose.onNodeWithText(searchLabel).assertIsDisplayed() + verify(viewModel, never()).startPatchDiscardProcess() + assertThat(exitFlowCount).isEqualTo(0) + } + + // endregion + + // region connect dialog + + @Test + fun showConnectDialogEvent_showsConnectDialog() { + setContent() + + emitEvent(CarelevoConnectPrepareEvent.ShowConnectDialog) + + compose.onNodeWithText(connectDialogTitle).assertIsDisplayed() + compose.onNodeWithText(DEVICE_NAME).assertIsDisplayed() + compose.onNodeWithText(confirmLabel).assertIsDisplayed() + compose.onNodeWithText(rescanLabel).assertIsDisplayed() + } + + @Test + fun connectDialog_confirm_callsStartConnectWithInputInsulin_andDismisses() { + setContent() + emitEvent(CarelevoConnectPrepareEvent.ShowConnectDialog) + + compose.onNodeWithText(confirmLabel).performClick() + compose.waitForIdle() + + verify(viewModel).startConnect(INPUT_INSULIN) + compose.onNodeWithText(connectDialogTitle).assertDoesNotExist() + verify(viewModel, never()).startScan() + } + + @Test + fun connectDialog_rescan_callsStartScan_andDismisses() { + setContent() + emitEvent(CarelevoConnectPrepareEvent.ShowConnectDialog) + + compose.onNodeWithText(rescanLabel).performClick() + compose.waitForIdle() + + verify(viewModel).startScan() + compose.onNodeWithText(connectDialogTitle).assertDoesNotExist() + verify(viewModel, never()).startConnect(INPUT_INSULIN) + } + + // endregion + + // region error-banner events + + @Test + fun scanFailedEvent_showsErrorBanner() { + setContent() + + emitEvent(CarelevoConnectPrepareEvent.ShowMessageScanFailed) + + compose.onNodeWithText(scanFailedMsg).assertIsDisplayed() + compose.onNodeWithText(step1Title).assertIsDisplayed() + } + + @Test + fun bluetoothNotEnabledEvent_showsErrorBanner() { + setContent() + + emitEvent(CarelevoConnectPrepareEvent.ShowMessageBluetoothNotEnabled) + + compose.onNodeWithText(bluetoothNotEnabledMsg).assertIsDisplayed() + } + + @Test + fun notSetUserSettingInfoEvent_showsProfileNotSetBanner() { + setContent() + + emitEvent(CarelevoConnectPrepareEvent.ShowMessageNotSetUserSettingInfo) + + compose.onNodeWithText(profileNotSetMsg).assertIsDisplayed() + } + + @Test + fun selectedDeviceIsEmptyEvent_showsPatchNotFoundBanner() { + setContent() + + emitEvent(CarelevoConnectPrepareEvent.ShowMessageSelectedDeviceIseEmpty) + + compose.onNodeWithText(patchNotFoundMsg).assertIsDisplayed() + } + + @Test + fun scanIsWorkingEvent_isIgnored() { + setContent() + + emitEvent(CarelevoConnectPrepareEvent.ShowMessageScanIsWorking) + + assertNoErrorBanner() + compose.onNodeWithText(connectDialogTitle).assertDoesNotExist() + compose.onNodeWithText(searchLabel).assertIsDisplayed() + } + + @Test + fun noActionEvent_isIgnored() { + setContent() + + emitEvent(CarelevoConnectPrepareEvent.NoAction) + + assertNoErrorBanner() + compose.onNodeWithText(connectDialogTitle).assertDoesNotExist() + compose.onNodeWithText(searchLabel).assertIsDisplayed() + } + + @Test + fun laterErrorEvent_replacesPreviousBanner() { + setContent() + emitEvent(CarelevoConnectPrepareEvent.ShowMessageScanFailed) + compose.onNodeWithText(scanFailedMsg).assertIsDisplayed() + + emitEvent(CarelevoConnectPrepareEvent.ShowMessageBluetoothNotEnabled) + + compose.onNodeWithText(bluetoothNotEnabledMsg).assertIsDisplayed() + compose.onNodeWithText(scanFailedMsg).assertDoesNotExist() + } + + // endregion + + // region terminal events + + @Test + fun connectFailedEvent_dismissesConnectDialog_andShowsErrorBanner() { + setContent() + emitEvent(CarelevoConnectPrepareEvent.ShowConnectDialog) + compose.onNodeWithText(connectDialogTitle).assertIsDisplayed() + + emitEvent(CarelevoConnectPrepareEvent.ConnectFailed) + + compose.onNodeWithText(connectDialogTitle).assertDoesNotExist() + compose.onNodeWithText(connectFailedMsg).assertIsDisplayed() + verify(sharedViewModel, never()).setPage(CarelevoPatchStep.SAFETY_CHECK) + } + + @Test + fun connectCompleteEvent_dismissesDialog_clearsError_andAdvancesToSafetyCheck() { + setContent() + emitEvent(CarelevoConnectPrepareEvent.ShowMessageScanFailed) + emitEvent(CarelevoConnectPrepareEvent.ShowConnectDialog) + compose.onNodeWithText(connectDialogTitle).assertIsDisplayed() + + emitEvent(CarelevoConnectPrepareEvent.ConnectComplete) + + compose.onNodeWithText(connectDialogTitle).assertDoesNotExist() + assertNoErrorBanner() + verify(sharedViewModel).setPage(CarelevoPatchStep.SAFETY_CHECK) + assertThat(exitFlowCount).isEqualTo(0) + } + + @Test + fun discardCompleteEvent_dismissesDiscardDialog_andExitsFlow() { + setContent() + compose.onNodeWithText(deactivateLabel).performClick() + compose.waitForIdle() + compose.onNodeWithText(discardDialogTitle).assertIsDisplayed() + + emitEvent(CarelevoConnectPrepareEvent.DiscardComplete) + + compose.onNodeWithText(discardDialogTitle).assertDoesNotExist() + assertThat(exitFlowCount).isEqualTo(1) + assertNoErrorBanner() + } + + @Test + fun discardFailedEvent_dismissesDiscardDialog_andShowsErrorBanner() { + setContent() + compose.onNodeWithText(deactivateLabel).performClick() + compose.waitForIdle() + compose.onNodeWithText(discardDialogTitle).assertIsDisplayed() + + emitEvent(CarelevoConnectPrepareEvent.DiscardFailed) + + compose.onNodeWithText(discardDialogTitle).assertDoesNotExist() + compose.onNodeWithText(discardFailedMsg).assertIsDisplayed() + assertThat(exitFlowCount).isEqualTo(0) + } + + // endregion + + private companion object { + + private const val INPUT_INSULIN = 250 + + /** Hardcoded in the connect dialog's `content` — the pump brand, deliberately not localized. */ + private const val DEVICE_NAME = "CareLevo" + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep03SafetyCheckTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep03SafetyCheckTest.kt new file mode 100644 index 000000000000..303c673df35c --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep03SafetyCheckTest.kt @@ -0,0 +1,714 @@ +package app.aaps.pump.carelevo.compose.patchflow + +import android.content.Context +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasProgressBarRangeInfo +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.common.MutableEventFlow +import app.aaps.pump.carelevo.common.asEventFlow +import app.aaps.pump.carelevo.common.model.Event +import app.aaps.pump.carelevo.presentation.model.CarelevoConnectSafetyCheckEvent +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchConnectionFlowViewModel +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchSafetyCheckViewModel +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * UI test for [CarelevoPatchFlowStep03SafetyCheck] - step 3 of the patch activation wizard, where the + * ~100-210 s safety check streams its countdown and the discard escape hatch is gated. + * + * ## How the ViewModels are injected + * The composable takes both VMs as plain parameters (the `hiltViewModel()` defaults live in + * `CarelevoPatchFlowScreen`, not here), so both are Mockito doubles - the module already mocks final + * Kotlin classes (see `CarelevoPatchSafetyCheckViewModelTest`, which mocks `CarelevoActivationExecutor`), + * so the inline mock maker covers these final `@HiltViewModel` classes too. The two StateFlows the + * screen collects are stubbed with real [MutableStateFlow]s and the one-shot event stream with a real + * production [MutableEventFlow] - so the collect/consume semantics under test are the real ones, and + * the test drives every UI state by emitting the same events the VM would. + * + * The VM's own logic (progress arithmetic, queue routing, ticker) is covered by + * `CarelevoPatchSafetyCheckViewModelTest`; this test covers only what the composable decides: the + * Ready/Progress/Success state machine, the error banner, the discard gate and the callbacks. + * + * ## Two conventions that matter + * - `progress`/`remainSec` are **seeded before `setContent`**: they are read through + * `collectAsStateWithLifecycle`, whose initial value is the StateFlow's current value, so seeding + * up-front exercises every rendering branch without depending on the test host's lifecycle state. + * - [emitEvent] brackets each emit with `waitForIdle()`. The leading one guarantees the composable's + * collector has consumed the previous slot, so the replay-1 `MutableEventFlow` always has a free + * buffer slot and `emit` cannot suspend inside `runBlocking` on the test (= main) thread; the + * trailing one lets the collector apply the event and recompose before the assertions run. + * + * `MaterialTheme` alone is enough (mirrors the file's own `@Preview`s): the screen and its shared + * `WizardStepLayout`/`ErrorBanner` read only `MaterialTheme` values plus the plain `AapsSpacing` + * object - no `AapsTheme`, hence no `LocalPreferences`. + * + * A phone-sized qualifier is forced so the tallest state (Success: title + desc + bar + countdown row + * + warning + retry desc + retry button, over the pinned button row) fits and `assertIsDisplayed` + * stays meaningful. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35], qualifiers = "w411dp-h891dp-xhdpi") +class CarelevoPatchFlowStep03SafetyCheckTest { + + @get:Rule + val compose = createComposeRule() + + /** The real production event flow - the screen's `LaunchedEffect` collects it exactly as it would the VM's. */ + private val eventSource = MutableEventFlow() + private val progressFlow = MutableStateFlow(null) + private val remainSecFlow = MutableStateFlow(null) + + private var exitFlowCount = 0 + + private lateinit var viewModel: CarelevoPatchSafetyCheckViewModel + private lateinit var sharedViewModel: CarelevoPatchConnectionFlowViewModel + + // Labels resolved from resources rather than hardcoded, so the test survives copy/locale changes. + private lateinit var startTitle: String + private lateinit var endTitle: String + private lateinit var startDesc: String + private lateinit var progressDesc: String + private lateinit var endDesc: String + private lateinit var warningText: String + private lateinit var retryDesc: String + private lateinit var btnSafetyCheck: String + private lateinit var btnNext: String + private lateinit var btnDiscard: String + private lateinit var btnRetry: String + private lateinit var btnConfirm: String + private lateinit var btnCancel: String + private lateinit var dialogTitle: String + private lateinit var dialogDesc: String + private lateinit var errBluetoothOff: String + private lateinit var errNotConnected: String + private lateinit var errSafetyCheckFailed: String + private lateinit var errDiscardFailed: String + + @Before + fun setUp() { + val context: Context = RuntimeEnvironment.getApplication() + startTitle = context.getString(R.string.carelevo_patch_safety_check_start_title) + endTitle = context.getString(R.string.carelevo_patch_safety_check_end_title) + startDesc = context.getString(R.string.carelevo_patch_safety_check_start_desc) + progressDesc = context.getString(R.string.carelevo_patch_safety_check_progress_desc) + endDesc = context.getString(R.string.carelevo_patch_safety_check_end_desc) + warningText = context.getString(R.string.carelevo_patch_safety_check_desc_warning) + retryDesc = context.getString(R.string.carelevo_patch_safety_check_retry_desc) + btnSafetyCheck = context.getString(R.string.carelevo_btn_safety_check) + btnNext = context.getString(R.string.carelevo_btn_next) + btnDiscard = context.getString(R.string.carelevo_btn_patch_expiration) + btnRetry = context.getString(R.string.carelevo_btn_retry) + btnConfirm = context.getString(R.string.carelevo_btn_confirm) + btnCancel = context.getString(R.string.carelevo_btn_cancel) + dialogTitle = context.getString(R.string.carelevo_dialog_patch_discard_message_title) + dialogDesc = context.getString(R.string.carelevo_dialog_patch_discard_message_desc) + errBluetoothOff = context.getString(R.string.carelevo_toast_msg_bluetooth_not_enabled) + errNotConnected = context.getString(R.string.carelevo_toast_msg_not_connected_waiting_retry) + errSafetyCheckFailed = context.getString(R.string.carelevo_toast_msg_safety_check_failed) + errDiscardFailed = context.getString(R.string.carelevo_toast_msg_discard_failed) + + viewModel = mock { + on { progress } doReturn progressFlow + on { remainSec } doReturn remainSecFlow + on { event } doReturn eventSource.asEventFlow() + on { isSafetyCheckPassed() } doReturn false + on { isCreated } doReturn false + } + sharedViewModel = mock() + } + + // ---- helpers ---------------------------------------------------------------------------------- + + private fun setScreen() { + compose.setContent { + MaterialTheme { + CarelevoPatchFlowStep03SafetyCheck( + viewModel = viewModel, + sharedViewModel = sharedViewModel, + onExitFlow = { exitFlowCount++ } + ) + } + } + // Let the two LaunchedEffects run, so the event collector is subscribed before any emit. + compose.waitForIdle() + } + + /** Push a one-shot event through the real EventFlow, exactly as the VM's `triggerEvent` would. */ + private fun emitEvent(event: CarelevoConnectSafetyCheckEvent) { + compose.waitForIdle() + runBlocking { eventSource.emit(event) } + compose.waitForIdle() + } + + /** Seed the countdown StateFlows. Must run before [setScreen] - see the class KDoc. */ + private fun seedCountdown(progress: Int?, remainSec: Long?) { + progressFlow.value = progress + remainSecFlow.value = remainSec + } + + private fun remainText(minutes: Long, seconds: Long): String = + RuntimeEnvironment.getApplication().getString(R.string.common_unit_remain_min_sec, minutes, seconds) + + private fun progressText(percent: Int): String = + RuntimeEnvironment.getApplication().getString(R.string.carelevo_progress_of_100, percent) + + /** + * The Ready title and the primary button share the literal "Safety Check", so the button must be + * addressed by its click action to stay unambiguous in the merged tree. + */ + private fun safetyCheckButton() = compose.onNode(hasText(btnSafetyCheck) and hasClickAction()) + + private fun progressBar(fraction: Float) = + compose.onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo(fraction, 0f..1f))) + + // ---- Ready (initial, safety check not yet passed) ---------------------------------------------- + + @Test + fun ready_showsStartTitleDescAndSafetyCheckButton() { + setScreen() + + compose.onNodeWithText(startDesc).assertIsDisplayed() + safetyCheckButton().assertIsDisplayed().assertIsEnabled() + compose.onNodeWithText(endTitle).assertDoesNotExist() + compose.onNodeWithText(btnNext).assertDoesNotExist() + } + + @Test + fun ready_hidesRetrySection() { + setScreen() + + compose.onNodeWithText(warningText).assertDoesNotExist() + compose.onNodeWithText(retryDesc).assertDoesNotExist() + compose.onNodeWithText(btnRetry).assertDoesNotExist() + } + + @Test + fun ready_showsNoErrorBanner() { + setScreen() + + compose.onNodeWithText(errBluetoothOff).assertDoesNotExist() + compose.onNodeWithText(errNotConnected).assertDoesNotExist() + compose.onNodeWithText(errSafetyCheckFailed).assertDoesNotExist() + compose.onNodeWithText(errDiscardFailed).assertDoesNotExist() + } + + @Test + fun ready_discardButtonIsEnabled() { + setScreen() + + compose.onNodeWithText(btnDiscard).assertIsDisplayed().assertIsEnabled() + } + + @Test + fun ready_suppressesCountdownRow_evenWhenTheFlowsAlreadyCarryValues() { + // showProgressDetails is false before the check starts: a stale countdown left over from the + // VM must not leak onto the start screen. + seedCountdown(progress = 50, remainSec = 90L) + setScreen() + + compose.onNodeWithText(progressText(50)).assertDoesNotExist() + compose.onNodeWithText(remainText(1, 30)).assertDoesNotExist() + } + + @Test + fun ready_progressBarStaysEmpty_evenWhenProgressIsSeeded() { + seedCountdown(progress = 50, remainSec = 90L) + setScreen() + + progressBar(0f).assertExists() + } + + // ---- LaunchedEffect: created latch + already-passed short circuit ------------------------------ + + @Test + fun onFirstComposition_marksTheViewModelCreated() { + setScreen() + + verify(viewModel).setIsCreated(true) + } + + @Test + fun onRecreation_doesNotReMarkAnAlreadyCreatedViewModel() { + whenever(viewModel.isCreated).thenReturn(true) + + setScreen() + + verify(viewModel, never()).setIsCreated(true) + } + + @Test + fun whenSafetyCheckAlreadyPassed_replaysCompletionIntoTheViewModel() { + whenever(viewModel.isSafetyCheckPassed()).thenReturn(true) + + setScreen() + + verify(viewModel).onSafetyCheckComplete() + } + + @Test + fun whenSafetyCheckNotPassed_doesNotReplayCompletion() { + setScreen() + + verify(viewModel, never()).onSafetyCheckComplete() + } + + @Test + fun whenSafetyCheckAlreadyPassed_opensDirectlyInTheSuccessState() { + // Cold-load into the wizard after a passed check: the step must not ask for the check again. + whenever(viewModel.isSafetyCheckPassed()).thenReturn(true) + seedCountdown(progress = 100, remainSec = 0L) + setScreen() + + compose.onNodeWithText(endTitle).assertIsDisplayed() + compose.onNodeWithText(endDesc).assertIsDisplayed() + compose.onNodeWithText(btnNext).assertIsDisplayed().assertIsEnabled() + compose.onNodeWithText(btnRetry).assertIsDisplayed() + safetyCheckButton().assertDoesNotExist() + } + + // ---- Ready interactions ----------------------------------------------------------------------- + + @Test + fun safetyCheckButtonClick_startsTheCheck() { + setScreen() + + safetyCheckButton().performClick() + compose.waitForIdle() + + verify(viewModel).startSafetyCheck() + } + + @Test + fun safetyCheckButtonClick_clearsAPreviousErrorBanner() { + setScreen() + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckFailed) + compose.onNodeWithText(errSafetyCheckFailed).assertIsDisplayed() + + safetyCheckButton().performClick() + compose.waitForIdle() + + compose.onNodeWithText(errSafetyCheckFailed).assertDoesNotExist() + verify(viewModel).startSafetyCheck() + } + + // ---- Progress --------------------------------------------------------------------------------- + + @Test + fun progressEvent_keepsStartTitleAndSwapsInTheProgressDesc() { + setScreen() + + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckProgress) + + compose.onNodeWithText(startTitle).assertIsDisplayed() + compose.onNodeWithText(progressDesc).assertIsDisplayed() + compose.onNodeWithText(startDesc).assertDoesNotExist() + compose.onNodeWithText(endDesc).assertDoesNotExist() + } + + @Test + fun progressEvent_replacesTheSafetyCheckButtonWithADisabledNext() { + setScreen() + + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckProgress) + + safetyCheckButton().assertDoesNotExist() + compose.onNodeWithText(btnNext).assertIsDisplayed().assertIsNotEnabled() + } + + @Test + fun progressEvent_disablesTheDiscardEscapeHatch() { + // The gate: a CmdDiscard enqueued behind the still-running CmdSafetyCheck would interleave + // confusingly, so discard must be unreachable while the check streams. + setScreen() + + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckProgress) + + compose.onNodeWithText(btnDiscard).assertIsDisplayed().assertIsNotEnabled() + } + + @Test + fun progressEvent_disabledDiscardTap_cannotOpenTheDialog() { + setScreen() + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckProgress) + + compose.onNodeWithText(btnDiscard).performClick() + compose.waitForIdle() + + compose.onNodeWithText(dialogTitle).assertDoesNotExist() + verify(viewModel, never()).startDiscardProcess() + } + + @Test + fun progressEvent_hidesTheRetrySection() { + setScreen() + + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckProgress) + + compose.onNodeWithText(warningText).assertDoesNotExist() + compose.onNodeWithText(retryDesc).assertDoesNotExist() + compose.onNodeWithText(btnRetry).assertDoesNotExist() + } + + @Test + fun progressEvent_showsCountdownAndPercent() { + seedCountdown(progress = 50, remainSec = 90L) + setScreen() + + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckProgress) + + compose.onNodeWithText(remainText(1, 30)).assertIsDisplayed() + compose.onNodeWithText(progressText(50)).assertIsDisplayed() + } + + @Test + fun progressEvent_fillsTheBarToTheReportedPercent() { + seedCountdown(progress = 50, remainSec = 90L) + setScreen() + + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckProgress) + + progressBar(0.5f).assertExists() + } + + @Test + fun progressEvent_withoutAnyCountdownValues_hidesTheDetailRowAndKeepsTheBarEmpty() { + seedCountdown(progress = null, remainSec = null) + setScreen() + + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckProgress) + + // Nothing to show yet (no Progress frame from the executor) - no "x/100", no countdown. + compose.onNodeWithText("/100", substring = true).assertDoesNotExist() + compose.onNodeWithText(remainText(0, 0)).assertDoesNotExist() + // ...and `progress ?: 0` keeps the bar at zero rather than crashing on the null. + progressBar(0f).assertExists() + compose.onNodeWithText(progressDesc).assertIsDisplayed() + } + + @Test + fun progressEvent_withOnlyRemainSec_showsTheCountdownAndNoPercent() { + seedCountdown(progress = null, remainSec = 45L) + setScreen() + + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckProgress) + + compose.onNodeWithText(remainText(0, 45)).assertIsDisplayed() + compose.onNodeWithText("/100", substring = true).assertDoesNotExist() + } + + @Test + fun progressEvent_withOnlyPercent_showsThePercentAndNoCountdown() { + seedCountdown(progress = 25, remainSec = null) + setScreen() + + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckProgress) + + compose.onNodeWithText(progressText(25)).assertIsDisplayed() + progressBar(0.25f).assertExists() + compose.onNodeWithText(remainText(0, 0)).assertDoesNotExist() + } + + @Test + fun progressEvent_clearsAPreviousErrorBanner() { + setScreen() + emitEvent(CarelevoConnectSafetyCheckEvent.ShowMessageBluetoothNotEnabled) + compose.onNodeWithText(errBluetoothOff).assertIsDisplayed() + + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckProgress) + + compose.onNodeWithText(errBluetoothOff).assertDoesNotExist() + compose.onNodeWithText(progressDesc).assertIsDisplayed() + } + + // ---- Success ---------------------------------------------------------------------------------- + + @Test + fun completeEvent_showsEndTitleDescAndRetrySection() { + seedCountdown(progress = 100, remainSec = 0L) + setScreen() + + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckComplete) + + compose.onNodeWithText(endTitle).assertIsDisplayed() + compose.onNodeWithText(endDesc).assertIsDisplayed() + compose.onNodeWithText(warningText).assertIsDisplayed() + compose.onNodeWithText(retryDesc).assertIsDisplayed() + compose.onNodeWithText(btnRetry).assertIsDisplayed().assertIsEnabled() + compose.onNodeWithText(startTitle).assertDoesNotExist() + } + + @Test + fun completeEvent_enablesNextAndRestoresTheDiscardEscapeHatch() { + setScreen() + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckProgress) + compose.onNodeWithText(btnDiscard).assertIsNotEnabled() + + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckComplete) + + compose.onNodeWithText(btnNext).assertIsDisplayed().assertIsEnabled() + compose.onNodeWithText(btnDiscard).assertIsEnabled() + safetyCheckButton().assertDoesNotExist() + } + + @Test + fun completeEvent_showsAFullBarAndFinishedCountdown() { + seedCountdown(progress = 100, remainSec = 0L) + setScreen() + + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckComplete) + + progressBar(1f).assertExists() + compose.onNodeWithText(progressText(100)).assertIsDisplayed() + compose.onNodeWithText(remainText(0, 0)).assertIsDisplayed() + } + + @Test + fun completeEvent_clearsAPreviousErrorBanner() { + setScreen() + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckFailed) + compose.onNodeWithText(errSafetyCheckFailed).assertIsDisplayed() + + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckComplete) + + compose.onNodeWithText(errSafetyCheckFailed).assertDoesNotExist() + compose.onNodeWithText(endTitle).assertIsDisplayed() + } + + @Test + fun nextButtonClick_advancesTheSharedFlow() { + setScreen() + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckComplete) + + compose.onNodeWithText(btnNext).performClick() + compose.waitForIdle() + + verify(sharedViewModel).advanceFromSafetyCheck() + } + + @Test + fun retryButtonClick_retriesAdditionalPriming() { + setScreen() + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckComplete) + + compose.onNodeWithText(btnRetry).performClick() + compose.waitForIdle() + + verify(viewModel).retryAdditionalPriming() + verify(sharedViewModel, never()).advanceFromSafetyCheck() + } + + @Test + fun retryButtonClick_clearsAPreviousErrorBanner() { + setScreen() + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckComplete) + emitEvent(CarelevoConnectSafetyCheckEvent.DiscardFailed) + compose.onNodeWithText(errDiscardFailed).assertIsDisplayed() + + compose.onNodeWithText(btnRetry).performClick() + compose.waitForIdle() + + compose.onNodeWithText(errDiscardFailed).assertDoesNotExist() + verify(viewModel).retryAdditionalPriming() + } + + // ---- Failure event ---------------------------------------------------------------------------- + + @Test + fun failedEvent_bannersTheFailureAndFallsBackToReady() { + whenever(viewModel.isSafetyCheckPassed()).thenReturn(true) + setScreen() + compose.onNodeWithText(endTitle).assertIsDisplayed() + + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckFailed) + + compose.onNodeWithText(errSafetyCheckFailed).assertIsDisplayed() + // Back to Ready: the check is offered again and the success-only affordances are gone. + compose.onNodeWithText(startDesc).assertIsDisplayed() + safetyCheckButton().assertIsDisplayed().assertIsEnabled() + compose.onNodeWithText(endTitle).assertDoesNotExist() + compose.onNodeWithText(btnRetry).assertDoesNotExist() + compose.onNodeWithText(btnNext).assertDoesNotExist() + } + + @Test + fun failedEvent_resetsTheVisibleCountdown_ratherThanStrandingItHalfRun() { + seedCountdown(progress = 50, remainSec = 30L) + setScreen() + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckProgress) + compose.onNodeWithText(progressText(50)).assertIsDisplayed() + + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckFailed) + + // The VM deliberately freezes its own progress/remainSec on failure, so the abandoned 50 % + // is still in the flows. Falling back to Ready must drop showProgressDetails and blank the + // bar, otherwise a stale half-run countdown sits above an untouched [Safety Check] button. + progressBar(0f).assertExists() + compose.onNodeWithText(progressText(50)).assertDoesNotExist() + compose.onNodeWithText(remainText(0, 30)).assertDoesNotExist() + } + + // ---- Message-only events ---------------------------------------------------------------------- + + @Test + fun bluetoothNotEnabledEvent_bannersWithoutLeavingReady() { + setScreen() + + emitEvent(CarelevoConnectSafetyCheckEvent.ShowMessageBluetoothNotEnabled) + + compose.onNodeWithText(errBluetoothOff).assertIsDisplayed() + compose.onNodeWithText(startDesc).assertIsDisplayed() + safetyCheckButton().assertIsEnabled() + } + + @Test + fun notConnectedEvent_bannersWithoutLeavingReady() { + setScreen() + + emitEvent(CarelevoConnectSafetyCheckEvent.ShowMessageCarelevoIsNotConnected) + + compose.onNodeWithText(errNotConnected).assertIsDisplayed() + compose.onNodeWithText(startDesc).assertIsDisplayed() + safetyCheckButton().assertIsEnabled() + } + + @Test + fun noActionEvent_changesNothing() { + setScreen() + + emitEvent(CarelevoConnectSafetyCheckEvent.NoAction) + + compose.onNodeWithText(startDesc).assertIsDisplayed() + safetyCheckButton().assertIsEnabled() + compose.onNodeWithText(btnDiscard).assertIsEnabled() + compose.onNodeWithText(errBluetoothOff).assertDoesNotExist() + compose.onNodeWithText(errSafetyCheckFailed).assertDoesNotExist() + assertThat(exitFlowCount).isEqualTo(0) + } + + @Test + fun aSecondBannerEvent_replacesTheFirstMessage() { + setScreen() + emitEvent(CarelevoConnectSafetyCheckEvent.ShowMessageBluetoothNotEnabled) + + emitEvent(CarelevoConnectSafetyCheckEvent.ShowMessageCarelevoIsNotConnected) + + compose.onNodeWithText(errNotConnected).assertIsDisplayed() + compose.onNodeWithText(errBluetoothOff).assertDoesNotExist() + } + + // ---- Discard dialog --------------------------------------------------------------------------- + + @Test + fun discardButtonClick_opensTheConfirmationDialog() { + setScreen() + + compose.onNodeWithText(btnDiscard).performClick() + compose.waitForIdle() + + compose.onNodeWithText(dialogTitle).assertIsDisplayed() + compose.onNodeWithText(dialogDesc).assertIsDisplayed() + compose.onNodeWithText(btnConfirm).assertIsDisplayed() + compose.onNodeWithText(btnCancel).assertIsDisplayed() + // Opening the dialog must not itself start anything. + verify(viewModel, never()).startDiscardProcess() + } + + @Test + fun discardDialogConfirm_startsDiscardAndClosesTheDialog() { + setScreen() + compose.onNodeWithText(btnDiscard).performClick() + compose.waitForIdle() + + compose.onNodeWithText(btnConfirm).performClick() + compose.waitForIdle() + + verify(viewModel).startDiscardProcess() + compose.onNodeWithText(dialogTitle).assertDoesNotExist() + // The flow is only exited once the VM reports DiscardComplete, not on the tap. + assertThat(exitFlowCount).isEqualTo(0) + } + + @Test + fun discardDialogCancel_closesTheDialogWithoutDiscarding() { + setScreen() + compose.onNodeWithText(btnDiscard).performClick() + compose.waitForIdle() + + compose.onNodeWithText(btnCancel).performClick() + compose.waitForIdle() + + compose.onNodeWithText(dialogTitle).assertDoesNotExist() + verify(viewModel, never()).startDiscardProcess() + assertThat(exitFlowCount).isEqualTo(0) + } + + @Test + fun discardCompleteEvent_closesTheDialogAndExitsTheFlow() { + setScreen() + compose.onNodeWithText(btnDiscard).performClick() + compose.waitForIdle() + compose.onNodeWithText(dialogTitle).assertIsDisplayed() + + emitEvent(CarelevoConnectSafetyCheckEvent.DiscardComplete) + + assertThat(exitFlowCount).isEqualTo(1) + compose.onNodeWithText(dialogTitle).assertDoesNotExist() + } + + @Test + fun discardFailedEvent_closesTheDialogAndBannersTheFailure() { + setScreen() + compose.onNodeWithText(btnDiscard).performClick() + compose.waitForIdle() + compose.onNodeWithText(dialogTitle).assertIsDisplayed() + + emitEvent(CarelevoConnectSafetyCheckEvent.DiscardFailed) + + compose.onNodeWithText(dialogTitle).assertDoesNotExist() + compose.onNodeWithText(errDiscardFailed).assertIsDisplayed() + // A failed discard must not silently drop the user out of the wizard. + assertThat(exitFlowCount).isEqualTo(0) + // ...and the step stays usable. + compose.onNodeWithText(startDesc).assertIsDisplayed() + safetyCheckButton().assertIsEnabled() + } + + @Test + fun discardDialog_isReachableFromTheSuccessStateToo() { + setScreen() + emitEvent(CarelevoConnectSafetyCheckEvent.SafetyCheckComplete) + + compose.onNodeWithText(btnDiscard).performClick() + compose.waitForIdle() + compose.onNodeWithText(btnConfirm).performClick() + compose.waitForIdle() + + verify(viewModel).startDiscardProcess() + compose.onNodeWithText(dialogTitle).assertDoesNotExist() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep04AttachTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep04AttachTest.kt new file mode 100644 index 000000000000..39ace39b85ff --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep04AttachTest.kt @@ -0,0 +1,154 @@ +package app.aaps.pump.carelevo.compose.patchflow + +import android.app.Application +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import app.aaps.core.ui.R as CoreUiR +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.presentation.type.CarelevoPatchStep +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchConnectionFlowViewModel +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Render + interaction tests for [CarelevoPatchFlowStep04Attach]. + * + * The step is a pure instruction sheet: it renders three numbered sections plus a closing hint and + * routes the single primary button to [CarelevoPatchConnectionFlowViewModel.setPage]. The VM is a + * mock (Mockito's inline mock maker handles the final class) because the composable only writes to + * it — it reads no VM state — so no stubbing is needed. + * + * Section texts live inside the scrollable content column of `WizardStepLayout`, so they are + * scrolled into view before asserting visibility; the button row is pinned and always visible. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class CarelevoPatchFlowStep04AttachTest { + + @get:Rule + val compose = createComposeRule() + + private val viewModel = mock() + + private lateinit var app: Application + + private lateinit var stepLabel1: String + private lateinit var stepLabel2: String + private lateinit var stepLabel3: String + private lateinit var title1: String + private lateinit var title2: String + private lateinit var title3: String + private lateinit var desc1: String + private lateinit var desc2: String + private lateinit var desc3: String + private lateinit var desc4: String + private lateinit var nextLabel: String + private lateinit var cancelLabel: String + + @Before + fun setUp() { + app = RuntimeEnvironment.getApplication() + stepLabel1 = app.getString(R.string.carelevo_patch_step_1) + stepLabel2 = app.getString(R.string.carelevo_patch_step_2) + stepLabel3 = app.getString(R.string.carelevo_patch_step_3) + title1 = app.getString(R.string.carelevo_patch_attach_step1_title) + title2 = app.getString(R.string.carelevo_patch_attach_step2_title) + title3 = app.getString(R.string.carelevo_patch_attach_step3_title) + desc1 = app.getString(R.string.carelevo_patch_attach_step1_desc) + desc2 = app.getString(R.string.carelevo_patch_attach_step2_desc) + desc3 = app.getString(R.string.carelevo_patch_attach_step3_desc) + desc4 = app.getString(R.string.carelevo_patch_attach_step4_desc) + nextLabel = app.getString(CoreUiR.string.next) + cancelLabel = app.getString(CoreUiR.string.cancel) + } + + private fun render() { + compose.setContent { + MaterialTheme { + CarelevoPatchFlowStep04Attach(viewModel = viewModel) + } + } + compose.waitForIdle() + } + + @Test + fun showsAllThreeStepLabels() { + render() + + compose.onNodeWithText(stepLabel1).performScrollTo().assertIsDisplayed() + compose.onNodeWithText(stepLabel2).performScrollTo().assertIsDisplayed() + compose.onNodeWithText(stepLabel3).performScrollTo().assertIsDisplayed() + } + + @Test + fun showsAllThreeSectionTitles() { + render() + + compose.onNodeWithText(title1).performScrollTo().assertIsDisplayed() + compose.onNodeWithText(title2).performScrollTo().assertIsDisplayed() + compose.onNodeWithText(title3).performScrollTo().assertIsDisplayed() + } + + @Test + fun showsAllThreeSectionDescriptions() { + render() + + compose.onNodeWithText(desc1).performScrollTo().assertIsDisplayed() + compose.onNodeWithText(desc2).performScrollTo().assertIsDisplayed() + compose.onNodeWithText(desc3).performScrollTo().assertIsDisplayed() + } + + @Test + fun showsClosingInstruction() { + render() + + compose.onNodeWithText(desc4).performScrollTo().assertIsDisplayed() + } + + @Test + fun primaryButton_isDisplayedAndEnabled() { + render() + + compose.onNodeWithText(nextLabel).assertIsDisplayed().assertIsEnabled() + } + + @Test + fun hasNoSecondaryButton() { + render() + + compose.onNodeWithText(cancelLabel).assertDoesNotExist() + } + + @Test + fun doesNotTouchViewModel_beforeAnyClick() { + render() + + verify(viewModel, never()).setPage(any()) + } + + @Test + fun primaryClick_advancesToNeedleInsertion() { + render() + + compose.onNodeWithText(nextLabel).performClick() + compose.waitForIdle() + + verify(viewModel).setPage(CarelevoPatchStep.NEEDLE_INSERTION) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep05NeedleInsertionTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep05NeedleInsertionTest.kt new file mode 100644 index 000000000000..ebcc25913013 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoPatchFlowStep05NeedleInsertionTest.kt @@ -0,0 +1,534 @@ +package app.aaps.pump.carelevo.compose.patchflow + +import android.content.Context +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.common.MutableEventFlow +import app.aaps.pump.carelevo.common.asEventFlow +import app.aaps.pump.carelevo.common.model.Event +import app.aaps.pump.carelevo.presentation.model.CarelevoConnectNeedleEvent +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchNeedleInsertionViewModel +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * UI test for [CarelevoPatchFlowStep05NeedleInsertion] - the needle-insertion step of the Carelevo + * patch activation wizard. + * + * The step is stateful: it renders one of two mutually exclusive layouts driven by the VM's + * `isNeedleInsert` StateFlow (insertion instructions vs. detach-applicator guidance), and it derives + * two further pieces of local state - `failCount` and `errorMessage` - purely from the VM's event + * flow. All three inputs are therefore driven here through a mocked + * [CarelevoPatchNeedleInsertionViewModel]: + * - `isNeedleInsert` is stubbed with a real [MutableStateFlow], seeded *before* `setContent` so + * `collectAsStateWithLifecycle` picks it up as the initial value (no lifecycle pumping needed), + * - `event` is stubbed with the driver's own [MutableEventFlow], created with a replay buffer large + * enough that `emit` never suspends (so [emitEvent] cannot deadlock the Robolectric main thread), + * - the imperative calls (`observePatchInfo`, `setIsCreated`, `startCheckNeedle`, `startSetBasal`, + * `startDiscardProcess`) are asserted with Mockito `verify`. + * + * Mocking the VM directly (rather than constructing the real one from mocked collaborators) keeps + * RxJava, the CommandQueue and the pump-sync bookkeeping entirely out of this test - the VM's own + * logic is not under test here, only the composable's reaction to it. The class is final Kotlin, but + * Mockito 5's inline mock maker (the default) handles that. + * + * Wrapped in [MaterialTheme] only: the step and everything it delegates to (`WizardStepLayout`, + * `ErrorBanner`, `CarelevoActionDialog`) read `MaterialTheme.colorScheme` / `typography` but no + * `AapsTheme`-specific values - this mirrors the file's own `@Preview`s. Spacing comes from + * `AapsSpacing`, a plain object needing no theme. + * + * A phone-sized qualifier is forced (matching `CarelevoAlarmScreenTest`) so the tall step content + * (banner + two instruction sections + warning row + retry counter + pinned buttons) fits on screen + * and `assertIsDisplayed` / `performClick` stay meaningful. + * + * Text is resolved from resources rather than hardcoded so the test survives label/locale changes. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35], qualifiers = "w411dp-h891dp-xhdpi") +class CarelevoPatchFlowStep05NeedleInsertionTest { + + @get:Rule + val compose = createComposeRule() + + private val viewModel = mock() + + /** Backs the mocked `isNeedleInsert`; seed before [setScreen]. */ + private val needleInsertedFlow = MutableStateFlow(false) + + /** + * Backs the mocked `event`. Replay is deliberately larger than the production default (1) so a + * test can queue several events without `emit` ever suspending on a slow collector. + */ + private val eventFlow = MutableEventFlow(replay = 16) + + private var exitFlowCalls = 0 + + // Not-inserted layout + private lateinit var step1Label: String + private lateinit var step2Label: String + private lateinit var step1Title: String + private lateinit var step1Desc: String + private lateinit var step2Title: String + private lateinit var step2Desc: String + private lateinit var warning: String + private lateinit var checkButton: String + private lateinit var retryButton: String + + // Inserted layout + private lateinit var injectedTitle: String + private lateinit var detachedButton: String + + // Discard dialog + private lateinit var deactivateButton: String + private lateinit var dialogTitle: String + private lateinit var dialogDesc: String + private lateinit var confirmButton: String + private lateinit var cancelButton: String + + // Error banner messages + private lateinit var bluetoothNotEnabledMsg: String + private lateinit var notConnectedMsg: String + private lateinit var profileNotSetMsg: String + private lateinit var needleCheckFailedMsg: String + private lateinit var discardFailedMsg: String + private lateinit var setBasalFailedMsg: String + + @Before + fun setUp() { + val context: Context = RuntimeEnvironment.getApplication() + + step1Label = context.getString(R.string.carelevo_patch_step_1) + step2Label = context.getString(R.string.carelevo_patch_step_2) + step1Title = context.getString(R.string.carelevo_patch_needle_insertion_step1_title) + step1Desc = context.getString(R.string.carelevo_patch_needle_insertion_step1_desc) + step2Title = context.getString(R.string.carelevo_patch_needle_insertion_step2_title) + step2Desc = context.getString(R.string.carelevo_patch_needle_insertion_step2_desc) + warning = context.getString(R.string.carelevo_patch_needle_insertion_desc_warning) + checkButton = context.getString(R.string.carelevo_btn_needle_insert_check) + retryButton = context.getString(R.string.carelevo_btn_retry) + + injectedTitle = context.getString(R.string.carelevo_dialog_patch_connect_needle_injected) + detachedButton = context.getString(R.string.carelevo_dialog_connect_detached) + + deactivateButton = context.getString(R.string.carelevo_btn_patch_expiration) + dialogTitle = context.getString(R.string.carelevo_dialog_patch_discard_message_title) + dialogDesc = context.getString(R.string.carelevo_dialog_patch_discard_message_desc) + confirmButton = context.getString(R.string.carelevo_btn_confirm) + cancelButton = context.getString(R.string.carelevo_btn_cancel) + + bluetoothNotEnabledMsg = context.getString(R.string.carelevo_toast_msg_bluetooth_not_enabled) + notConnectedMsg = context.getString(R.string.carelevo_toast_msg_not_connected_waiting_retry) + profileNotSetMsg = context.getString(R.string.carelevo_toast_msg_profile_not_set) + needleCheckFailedMsg = context.getString(R.string.carelevo_toast_msg_needle_check_failed) + discardFailedMsg = context.getString(R.string.carelevo_toast_msg_discard_failed) + setBasalFailedMsg = context.getString(R.string.carelevo_toast_msg_set_basal_failed) + + whenever(viewModel.isNeedleInsert).thenReturn(needleInsertedFlow) + whenever(viewModel.event).thenReturn(eventFlow.asEventFlow()) + whenever(viewModel.isCreated).thenReturn(false) + } + + /** Remaining-attempts label for a given failure count, mirroring the step's own clamping. */ + private fun retryCountText(failCount: Int): String = + RuntimeEnvironment.getApplication().getString( + R.string.carelevo_dialog_patch_needle_retry_count, + (MAX_NEEDLE_CHECK_COUNT - failCount).coerceAtLeast(0) + ) + + /** Seeds `isNeedleInsert` and hosts the step, with `onExitFlow` wired to a counter. */ + private fun setScreen(needleInserted: Boolean = false) { + needleInsertedFlow.value = needleInserted + compose.setContent { + MaterialTheme { + CarelevoPatchFlowStep05NeedleInsertion( + viewModel = viewModel, + onExitFlow = { exitFlowCalls++ } + ) + } + } + compose.waitForIdle() + } + + /** + * Pushes an event through the mocked VM's flow and lets the step's collector + the resulting + * recomposition settle. `emit` never suspends here (see [eventFlow]), so `runBlocking` cannot + * deadlock the Robolectric main thread. + */ + private fun emitEvent(event: Event) { + runBlocking { eventFlow.emit(event) } + compose.waitForIdle() + } + + // --------------------------------------------------------------------------------------------- + // First-composition side effects + // --------------------------------------------------------------------------------------------- + + @Test + fun firstComposition_whenNotCreated_observesPatchInfoAndMarksCreated() { + whenever(viewModel.isCreated).thenReturn(false) + + setScreen() + + verify(viewModel).observePatchInfo() + verify(viewModel).setIsCreated(true) + } + + @Test + fun firstComposition_whenAlreadyCreated_doesNotObservePatchInfoAgain() { + whenever(viewModel.isCreated).thenReturn(true) + + setScreen() + + verify(viewModel, never()).observePatchInfo() + verify(viewModel, never()).setIsCreated(true) + } + + // --------------------------------------------------------------------------------------------- + // Needle NOT inserted - insertion instructions + // --------------------------------------------------------------------------------------------- + + @Test + fun needleNotInserted_rendersBothStepSectionsWarningAndCheckButton() { + setScreen(needleInserted = false) + + compose.onNodeWithText(step1Label).assertIsDisplayed() + compose.onNodeWithText(step1Title).assertIsDisplayed() + compose.onNodeWithText(step1Desc).assertIsDisplayed() + compose.onNodeWithText(step2Label).assertIsDisplayed() + compose.onNodeWithText(step2Title).assertIsDisplayed() + compose.onNodeWithText(step2Desc).assertIsDisplayed() + compose.onNodeWithText(warning).assertIsDisplayed() + compose.onNodeWithText(checkButton).assertIsDisplayed() + compose.onNodeWithText(deactivateButton).assertIsDisplayed() + } + + @Test + fun needleNotInserted_showsNeitherRetryStateNorErrorBannerNorDetachedLayout() { + setScreen(needleInserted = false) + + // No failure yet: primary button is the first-attempt label, no remaining-attempts counter. + compose.onNodeWithText(retryButton).assertDoesNotExist() + compose.onNodeWithText(retryCountText(failCount = 1)).assertDoesNotExist() + // No event yet: no banner. + compose.onNodeWithText(needleCheckFailedMsg).assertDoesNotExist() + // The detach-applicator layout is the other branch. + compose.onNodeWithText(injectedTitle).assertDoesNotExist() + compose.onNodeWithText(detachedButton).assertDoesNotExist() + // The discard dialog is closed until the user asks for it. + compose.onNodeWithText(dialogTitle).assertDoesNotExist() + } + + @Test + fun needleNotInserted_checkButtonIsEnabledAndStartsNeedleCheck() { + setScreen(needleInserted = false) + + compose.onNodeWithText(checkButton).assertIsEnabled() + compose.onNodeWithText(checkButton).performClick() + + verify(viewModel).startCheckNeedle() + } + + @Test + fun needleNotInserted_checkButtonClickClearsExistingErrorBanner() { + setScreen(needleInserted = false) + emitEvent(CarelevoConnectNeedleEvent.CheckNeedleError) + compose.onNodeWithText(needleCheckFailedMsg).assertIsDisplayed() + + compose.onNodeWithText(checkButton).performClick() + + compose.onNodeWithText(needleCheckFailedMsg).assertDoesNotExist() + verify(viewModel).startCheckNeedle() + } + + // --------------------------------------------------------------------------------------------- + // Error banner - one test per event branch + // --------------------------------------------------------------------------------------------- + + @Test + fun bluetoothNotEnabledEvent_showsErrorBanner() { + setScreen() + + emitEvent(CarelevoConnectNeedleEvent.ShowMessageBluetoothNotEnabled) + + compose.onNodeWithText(bluetoothNotEnabledMsg).assertIsDisplayed() + } + + @Test + fun carelevoNotConnectedEvent_showsErrorBanner() { + setScreen() + + emitEvent(CarelevoConnectNeedleEvent.ShowMessageCarelevoIsNotConnected) + + compose.onNodeWithText(notConnectedMsg).assertIsDisplayed() + } + + @Test + fun profileNotSetEvent_showsErrorBanner() { + setScreen() + + emitEvent(CarelevoConnectNeedleEvent.ShowMessageProfileNotSet) + + compose.onNodeWithText(profileNotSetMsg).assertIsDisplayed() + } + + @Test + fun checkNeedleErrorEvent_showsErrorBanner() { + setScreen() + + emitEvent(CarelevoConnectNeedleEvent.CheckNeedleError) + + compose.onNodeWithText(needleCheckFailedMsg).assertIsDisplayed() + } + + @Test + fun setBasalFailedEvent_showsErrorBanner() { + setScreen() + + emitEvent(CarelevoConnectNeedleEvent.SetBasalFailed) + + compose.onNodeWithText(setBasalFailedMsg).assertIsDisplayed() + } + + @Test + fun checkNeedleCompleteEvent_clearsErrorBanner() { + setScreen() + emitEvent(CarelevoConnectNeedleEvent.CheckNeedleError) + compose.onNodeWithText(needleCheckFailedMsg).assertIsDisplayed() + + emitEvent(CarelevoConnectNeedleEvent.CheckNeedleComplete(result = true)) + + compose.onNodeWithText(needleCheckFailedMsg).assertDoesNotExist() + } + + @Test + fun noActionEvent_leavesContentUnchangedAndDoesNotExitFlow() { + setScreen() + + emitEvent(CarelevoConnectNeedleEvent.NoAction) + + compose.onNodeWithText(checkButton).assertIsDisplayed() + compose.onNodeWithText(needleCheckFailedMsg).assertDoesNotExist() + assertThat(exitFlowCalls).isEqualTo(0) + } + + // --------------------------------------------------------------------------------------------- + // Needle check failures - retry state and the exit-on-max threshold + // --------------------------------------------------------------------------------------------- + + @Test + fun checkNeedleFailedBelowMax_swapsToRetryButtonAndShowsRemainingAttempts() { + setScreen() + + emitEvent(CarelevoConnectNeedleEvent.CheckNeedleFailed(failedCount = 1)) + + compose.onNodeWithText(retryButton).assertIsDisplayed() + compose.onNodeWithText(checkButton).assertDoesNotExist() + compose.onNodeWithText(retryCountText(failCount = 1)).assertIsDisplayed() + assertThat(exitFlowCalls).isEqualTo(0) + } + + @Test + fun checkNeedleFailedBelowMax_retryButtonClearsBannerAndStartsNeedleCheck() { + setScreen() + emitEvent(CarelevoConnectNeedleEvent.CheckNeedleError) + emitEvent(CarelevoConnectNeedleEvent.CheckNeedleFailed(failedCount = 2)) + compose.onNodeWithText(needleCheckFailedMsg).assertIsDisplayed() + compose.onNodeWithText(retryCountText(failCount = 2)).assertIsDisplayed() + + compose.onNodeWithText(retryButton).performClick() + + compose.onNodeWithText(needleCheckFailedMsg).assertDoesNotExist() + verify(viewModel).startCheckNeedle() + } + + @Test + fun checkNeedleFailedAtMax_exitsFlowAndShowsNoRemainingAttempts() { + setScreen() + + emitEvent(CarelevoConnectNeedleEvent.CheckNeedleFailed(failedCount = MAX_NEEDLE_CHECK_COUNT)) + + assertThat(exitFlowCalls).isEqualTo(1) + compose.onNodeWithText(retryCountText(failCount = MAX_NEEDLE_CHECK_COUNT)).assertIsDisplayed() + } + + @Test + fun checkNeedleFailedAboveMax_clampsRemainingAttemptsToZeroAndExitsFlow() { + setScreen() + + emitEvent(CarelevoConnectNeedleEvent.CheckNeedleFailed(failedCount = MAX_NEEDLE_CHECK_COUNT + 2)) + + assertThat(exitFlowCalls).isEqualTo(1) + // (3 - 5) would be negative; the step coerces it to 0 rather than showing "-2 attempts left". + compose.onNodeWithText(retryCountText(failCount = MAX_NEEDLE_CHECK_COUNT + 2)).assertIsDisplayed() + } + + // --------------------------------------------------------------------------------------------- + // Discard dialog + // --------------------------------------------------------------------------------------------- + + @Test + fun deactivateButton_isEnabledAndOpensDiscardDialog() { + setScreen() + + compose.onNodeWithText(deactivateButton).assertIsEnabled() + compose.onNodeWithText(deactivateButton).performClick() + + compose.onNodeWithText(dialogTitle).assertIsDisplayed() + compose.onNodeWithText(dialogDesc).assertIsDisplayed() + compose.onNodeWithText(confirmButton).assertIsDisplayed() + compose.onNodeWithText(cancelButton).assertIsDisplayed() + verify(viewModel, never()).startDiscardProcess() + } + + @Test + fun discardDialog_confirmStartsDiscardAndClosesDialog() { + setScreen() + compose.onNodeWithText(deactivateButton).performClick() + + compose.onNodeWithText(confirmButton).performClick() + + verify(viewModel).startDiscardProcess() + compose.onNodeWithText(dialogTitle).assertDoesNotExist() + } + + @Test + fun discardDialog_cancelClosesDialogWithoutStartingDiscard() { + setScreen() + compose.onNodeWithText(deactivateButton).performClick() + + compose.onNodeWithText(cancelButton).performClick() + + compose.onNodeWithText(dialogTitle).assertDoesNotExist() + verify(viewModel, never()).startDiscardProcess() + } + + @Test + fun discardCompleteEvent_closesDialogAndExitsFlow() { + setScreen() + compose.onNodeWithText(deactivateButton).performClick() + compose.onNodeWithText(dialogTitle).assertIsDisplayed() + + emitEvent(CarelevoConnectNeedleEvent.DiscardComplete) + + compose.onNodeWithText(dialogTitle).assertDoesNotExist() + assertThat(exitFlowCalls).isEqualTo(1) + } + + @Test + fun discardFailedEvent_closesDialogShowsErrorBannerAndStaysInFlow() { + setScreen() + compose.onNodeWithText(deactivateButton).performClick() + compose.onNodeWithText(dialogTitle).assertIsDisplayed() + + emitEvent(CarelevoConnectNeedleEvent.DiscardFailed) + + compose.onNodeWithText(dialogTitle).assertDoesNotExist() + compose.onNodeWithText(discardFailedMsg).assertIsDisplayed() + assertThat(exitFlowCalls).isEqualTo(0) + } + + // --------------------------------------------------------------------------------------------- + // Needle inserted - detach applicator / start delivery + // --------------------------------------------------------------------------------------------- + + @Test + fun needleInserted_rendersInjectedMessageGuideAndDetachedButton() { + setScreen(needleInserted = true) + + compose.onNodeWithText(injectedTitle).assertIsDisplayed() + // The guide is HTML-rendered from a multi-line resource, so match a stable fragment only. + compose.onNodeWithText(GUIDE_FRAGMENT, substring = true).assertIsDisplayed() + compose.onNodeWithText(detachedButton).assertIsDisplayed() + compose.onNodeWithText(deactivateButton).assertIsDisplayed() + } + + @Test + fun needleInserted_doesNotRenderInsertionInstructions() { + setScreen(needleInserted = true) + + compose.onNodeWithText(step1Label).assertDoesNotExist() + compose.onNodeWithText(step2Label).assertDoesNotExist() + compose.onNodeWithText(warning).assertDoesNotExist() + compose.onNodeWithText(checkButton).assertDoesNotExist() + compose.onNodeWithText(retryButton).assertDoesNotExist() + } + + @Test + fun needleInserted_detachedButtonIsEnabledAndStartsBasal() { + setScreen(needleInserted = true) + + compose.onNodeWithText(detachedButton).assertIsEnabled() + compose.onNodeWithText(detachedButton).performClick() + + verify(viewModel).startSetBasal() + } + + @Test + fun needleInserted_setBasalFailedEvent_showsErrorBannerInDetachLayout() { + setScreen(needleInserted = true) + + emitEvent(CarelevoConnectNeedleEvent.SetBasalFailed) + + compose.onNodeWithText(setBasalFailedMsg).assertIsDisplayed() + compose.onNodeWithText(injectedTitle).assertIsDisplayed() + } + + @Test + fun needleInserted_detachedButtonClickClearsExistingErrorBanner() { + setScreen(needleInserted = true) + emitEvent(CarelevoConnectNeedleEvent.SetBasalFailed) + compose.onNodeWithText(setBasalFailedMsg).assertIsDisplayed() + + compose.onNodeWithText(detachedButton).performClick() + + compose.onNodeWithText(setBasalFailedMsg).assertDoesNotExist() + verify(viewModel).startSetBasal() + } + + @Test + fun needleInserted_setBasalCompleteEvent_exitsFlow() { + setScreen(needleInserted = true) + + emitEvent(CarelevoConnectNeedleEvent.SetBasalComplete) + + assertThat(exitFlowCalls).isEqualTo(1) + } + + @Test + fun needleInserted_deactivateButtonOpensDiscardDialog() { + setScreen(needleInserted = true) + + compose.onNodeWithText(deactivateButton).performClick() + + compose.onNodeWithText(dialogTitle).assertIsDisplayed() + compose.onNodeWithText(confirmButton).assertIsDisplayed() + } + + companion object { + + /** Mirrors the step's own private `MAX_NEEDLE_CHECK_COUNT`. */ + private const val MAX_NEEDLE_CHECK_COUNT = 3 + + /** A single-line, whitespace-stable fragment of the detach-applicator guide resource. */ + private const val GUIDE_FRAGMENT = "Insulin delivery will begin." + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoSelectInsulinStepTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoSelectInsulinStepTest.kt new file mode 100644 index 000000000000..efaaa20efbfb --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoSelectInsulinStepTest.kt @@ -0,0 +1,228 @@ +package app.aaps.pump.carelevo.compose.patchflow + +import android.app.Application +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import app.aaps.core.data.model.ICfg +import app.aaps.core.interfaces.R as CoreInterfacesR +import app.aaps.core.ui.R as CoreUiR +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchConnectionFlowViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Render + interaction tests for [CarelevoSelectInsulinStep]. + * + * The step is thin glue: it collects three VM StateFlows, feeds the shared + * `app.aaps.core.ui.compose.insulin.SelectInsulin` picker (always `initialExpanded = true`), gates + * the primary button on `selectedInsulin != null`, and routes the three interactions back to the VM + * (`selectInsulin` / `advanceFromInsulin` / `exitWizard`). + * + * The VM is a Mockito mock (inline mock maker handles the final class) with real [MutableStateFlow] + * backing each collected property, so every branch is driven by simply setting a flow value before + * rendering, and every interaction is asserted with `verify`. + * + * Note the picker filters rows by the selected insulin's concentration: with [fiasp] selected only + * the U100 insulins are listed, [lyumjev] (U200) is filtered out. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class CarelevoSelectInsulinStepTest { + + @get:Rule + val compose = createComposeRule() + + private val fiasp = ICfg("Fiasp U100", peak = 55, dia = 5.0, concentration = 1.0) + private val novoRapid = ICfg("NovoRapid U100", peak = 75, dia = 5.0, concentration = 1.0) + private val lyumjev = ICfg("Lyumjev U200", peak = 45, dia = 5.0, concentration = 2.0) + + private val availableInsulins = MutableStateFlow>(emptyList()) + private val selectedInsulin = MutableStateFlow(null) + private val activeInsulinLabel = MutableStateFlow(null) + + private val viewModel = mock() + + private lateinit var app: Application + private lateinit var currentInsulinLabel: String + private lateinit var changeLabel: String + private lateinit var concentrationLabel: String + private lateinit var u100Label: String + private lateinit var nextLabel: String + private lateinit var cancelLabel: String + + @Before + fun setUp() { + app = RuntimeEnvironment.getApplication() + currentInsulinLabel = app.getString(CoreUiR.string.current_insulin) + changeLabel = app.getString(CoreUiR.string.change_insulin) + concentrationLabel = app.getString(CoreUiR.string.concentration_label) + u100Label = app.getString(CoreInterfacesR.string.u100) + nextLabel = app.getString(CoreUiR.string.next) + cancelLabel = app.getString(CoreUiR.string.cancel) + + whenever(viewModel.availableInsulins).thenReturn(availableInsulins) + whenever(viewModel.selectedInsulin).thenReturn(selectedInsulin) + whenever(viewModel.activeInsulinLabel).thenReturn(activeInsulinLabel) + whenever(viewModel.concentrationEnabled).thenReturn(false) + } + + private fun render() { + compose.setContent { + MaterialTheme { + CarelevoSelectInsulinStep(viewModel = viewModel) + } + } + compose.waitForIdle() + } + + /** Seeds the common "three insulins configured, Fiasp active and selected" case. */ + private fun seedThreeInsulins() { + availableInsulins.value = listOf(fiasp, novoRapid, lyumjev) + selectedInsulin.value = fiasp + activeInsulinLabel.value = fiasp.insulinLabel + } + + @Test + fun showsSelectedInsulinInHeader_andExpandedList() { + seedThreeInsulins() + render() + + compose.onNodeWithText(changeLabel).performScrollTo().assertIsDisplayed() + // "Fiasp U100" is both the header value and the (active, selected) list row. + compose.onAllNodesWithText(fiasp.insulinLabel).assertCountEquals(2) + // "Currently active" labels the header and marks the active row. + compose.onAllNodesWithText(currentInsulinLabel).assertCountEquals(2) + } + + @Test + fun listsInsulinsOfSelectedConcentration_andFiltersOthersOut() { + seedThreeInsulins() + render() + + compose.onNodeWithText(novoRapid.insulinLabel).performScrollTo().assertIsDisplayed() + compose.onNodeWithText(lyumjev.insulinLabel).assertDoesNotExist() + } + + @Test + fun selectingInsulin_delegatesToViewModel() { + seedThreeInsulins() + render() + + compose.onNodeWithText(novoRapid.insulinLabel).performScrollTo().performClick() + compose.waitForIdle() + + verify(viewModel).selectInsulin(novoRapid) + } + + @Test + fun primaryButton_isEnabled_whenInsulinSelected() { + seedThreeInsulins() + render() + + compose.onNodeWithText(nextLabel).assertIsDisplayed().assertIsEnabled() + } + + @Test + fun primaryClick_advancesFromInsulin() { + seedThreeInsulins() + render() + + compose.onNodeWithText(nextLabel).performClick() + compose.waitForIdle() + + verify(viewModel).advanceFromInsulin() + } + + @Test + fun primaryButton_isDisabled_whenNothingSelected() { + availableInsulins.value = listOf(fiasp, novoRapid) + selectedInsulin.value = null + activeInsulinLabel.value = null + render() + + compose.onNodeWithText(nextLabel).assertIsDisplayed().assertIsNotEnabled() + + compose.onNodeWithText(nextLabel).performClick() + compose.waitForIdle() + + verify(viewModel, never()).advanceFromInsulin() + } + + @Test + fun secondaryButton_isDisplayed_andExitsWizard() { + seedThreeInsulins() + render() + + compose.onNodeWithText(cancelLabel).assertIsDisplayed().assertIsEnabled() + + compose.onNodeWithText(cancelLabel).performClick() + compose.waitForIdle() + + verify(viewModel).exitWizard() + } + + @Test + fun emptyInsulinList_fallsBackToActiveLabel_andDisablesPrimary() { + availableInsulins.value = emptyList() + selectedInsulin.value = null + activeInsulinLabel.value = fiasp.insulinLabel + render() + + // With no rows to render, the active label is shown once — in the header only. + compose.onNodeWithText(fiasp.insulinLabel).performScrollTo().assertIsDisplayed() + compose.onNodeWithText(changeLabel).performScrollTo().assertIsDisplayed() + compose.onNodeWithText(nextLabel).assertIsNotEnabled() + } + + @Test + fun concentrationDropdown_isShown_whenEnabledAndMultipleConcentrations() { + whenever(viewModel.concentrationEnabled).thenReturn(true) + seedThreeInsulins() + render() + + compose.onNodeWithText(concentrationLabel).performScrollTo().assertIsDisplayed() + compose.onNodeWithText(u100Label).performScrollTo().assertIsDisplayed() + } + + @Test + fun concentrationDropdown_isHidden_whenConcentrationDisabled() { + whenever(viewModel.concentrationEnabled).thenReturn(false) + seedThreeInsulins() + render() + + compose.onNodeWithText(concentrationLabel).assertDoesNotExist() + } + + @Test + fun concentrationDropdown_isHidden_whenOnlyOneConcentrationAvailable() { + whenever(viewModel.concentrationEnabled).thenReturn(true) + availableInsulins.value = listOf(fiasp, novoRapid) + selectedInsulin.value = fiasp + activeInsulinLabel.value = fiasp.insulinLabel + render() + + compose.onNodeWithText(concentrationLabel).assertDoesNotExist() + // The picker itself still renders both same-concentration insulins. + compose.onNodeWithText(novoRapid.insulinLabel).performScrollTo().assertIsDisplayed() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoSetAmountStepTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoSetAmountStepTest.kt new file mode 100644 index 000000000000..d100a038394d --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/compose/patchflow/CarelevoSetAmountStepTest.kt @@ -0,0 +1,263 @@ +package app.aaps.pump.carelevo.compose.patchflow + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToIndex +import app.aaps.core.ui.R as CoreUiR +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.config.FillConfig +import app.aaps.pump.carelevo.presentation.viewmodel.CarelevoPatchConnectionFlowViewModel +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Robolectric Compose tests for [CarelevoSetAmountStep] — the wizard step that picks the insulin + * fill amount on a wheel picker built from [FillConfig] (50..300 step 10). + * + * **ViewModel:** [CarelevoPatchConnectionFlowViewModel] is a final Hilt VM, but the step touches + * exactly three of its members — `inputInsulin` (read once to seed the wheel), `confirmAmount` and + * `exitWizard`. It is therefore mocked outright (Mockito 5's inline mock maker is the default, and + * this module already mocks the final `CarelevoPatch` the same way). Mocking rather than + * constructing the real VM keeps `exitWizard`'s `viewModelScope.launch` from running, so no + * `Dispatchers.setMain` override is needed — overriding Main would fight the Compose test clock. + * + * **Why value assertions are relational, not exact:** the picker seeds the list with + * `initialFirstVisibleItemIndex`, which places the seeded row at the *top* of the viewport rather + * than under the centre overlay. The `snapshotFlow` effect then reports whichever row actually sits + * at the viewport centre and snaps `selectedValue` to it. The exact settled row is a function of + * measured pixel geometry, so these tests assert the invariants that matter — the confirmed amount + * is always one of the configured grid values, never outside 50..300, and moves in the direction + * scrolled — instead of hard-coding a row index that layout maths could shift. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class CarelevoSetAmountStepTest { + + @get:Rule + val compose = createComposeRule() + + private val viewModel: CarelevoPatchConnectionFlowViewModel = mock() + + /** The exact grid the picker offers: 50, 60, ... 300. */ + private val values = (FillConfig.FILL_MIN_UNITS..FillConfig.FILL_MAX_UNITS step FillConfig.FILL_STEP_UNITS).toList() + + private lateinit var rangeText: String + private lateinit var nextLabel: String + private lateinit var cancelLabel: String + + @Before + fun setUp() { + val context = RuntimeEnvironment.getApplication() + rangeText = context.getString(R.string.patch_prepare_dialog_msg_insulin_range, FillConfig.FILL_MIN_UNITS, FillConfig.FILL_MAX_UNITS) + nextLabel = context.getString(CoreUiR.string.next) + cancelLabel = context.getString(CoreUiR.string.cancel) + } + + /** Seeds the VM's stored fill amount and renders the step. */ + private fun render(inputInsulin: Int) { + whenever(viewModel.inputInsulin).thenReturn(inputInsulin) + compose.setContent { + MaterialTheme { + CarelevoSetAmountStep(viewModel = viewModel) + } + } + } + + /** Presses Next and returns the amount handed to [CarelevoPatchConnectionFlowViewModel.confirmAmount]. */ + private fun pressNextAndCaptureAmount(): Int { + compose.onNodeWithText(nextLabel).performClick() + compose.waitForIdle() + val captor = argumentCaptor() + verify(viewModel).confirmAmount(captor.capture()) + return captor.firstValue + } + + // ---- static content ------------------------------------------------------------------------- + + @Test + fun rendersRangeText_fromFillConfigBounds() { + render(FillConfig.FILL_MAX_UNITS) + + compose.onNodeWithText(rangeText).assertIsDisplayed() + } + + @Test + fun rendersBothWizardButtons_enabled() { + render(FillConfig.FILL_MAX_UNITS) + + compose.onNodeWithText(nextLabel).assertIsDisplayed().assertIsEnabled() + compose.onNodeWithText(cancelLabel).assertIsDisplayed().assertIsEnabled() + } + + // ---- wheel seeding -------------------------------------------------------------------------- + + @Test + fun seedingAtMinimum_rendersLowRows_andNotTheMaximum() { + render(FillConfig.FILL_MIN_UNITS) + + compose.onNodeWithText("50").assertIsDisplayed() + compose.onNodeWithText("60").assertExists() + compose.onNodeWithText("70").assertExists() + // Far end of a 26-row lazy list is never composed from the first row. + compose.onNodeWithText("300").assertDoesNotExist() + } + + @Test + fun seedingAtMaximum_rendersHighRows_andNotTheMinimum() { + render(FillConfig.FILL_MAX_UNITS) + + compose.onNodeWithText("300").assertExists() + compose.onNodeWithText("50").assertDoesNotExist() + } + + // ---- confirm / cancel ----------------------------------------------------------------------- + + @Test + fun next_confirmsAGridValueWithinBounds() { + render(150) + + val confirmed = pressNextAndCaptureAmount() + + assertThat(confirmed).isIn(values) + assertThat(confirmed).isAtLeast(FillConfig.FILL_MIN_UNITS) + assertThat(confirmed).isAtMost(FillConfig.FILL_MAX_UNITS) + } + + @Test + fun next_doesNotExitTheWizard() { + render(150) + + compose.onNodeWithText(nextLabel).performClick() + compose.waitForIdle() + + verify(viewModel, never()).exitWizard() + } + + @Test + fun cancel_exitsWizard_withoutConfirmingAnAmount() { + render(150) + + compose.onNodeWithText(cancelLabel).performClick() + compose.waitForIdle() + + verify(viewModel).exitWizard() + verify(viewModel, never()).confirmAmount(any()) + } + + // ---- seeded-value validation ---------------------------------------------------------------- + + @Test + fun offGridSeedValue_snapsToAGridValue() { + // 55 is not on the 10-unit grid: indexOf() misses, the index coerces to 0 and the centring + // effect pulls selection back onto a real row. + render(55) + + val confirmed = pressNextAndCaptureAmount() + + assertThat(confirmed).isNotEqualTo(55) + assertThat(confirmed).isIn(values) + } + + @Test + fun belowRangeSeedValue_coercesIntoRange() { + render(0) + + compose.onNodeWithText("50").assertIsDisplayed() + val confirmed = pressNextAndCaptureAmount() + + assertThat(confirmed).isIn(values) + assertThat(confirmed).isAtLeast(FillConfig.FILL_MIN_UNITS) + } + + @Test + fun aboveRangeSeedValue_coercesIntoRange() { + render(500) + + val confirmed = pressNextAndCaptureAmount() + + assertThat(confirmed).isIn(values) + assertThat(confirmed).isAtMost(FillConfig.FILL_MAX_UNITS) + } + + // ---- interaction ---------------------------------------------------------------------------- + + @Test + fun tappingAWheelRow_movesSelectionAndConfirmsOnce() { + render(FillConfig.FILL_MIN_UNITS) + + compose.onNodeWithText("70").performClick() + compose.waitForIdle() + + val confirmed = pressNextAndCaptureAmount() + + assertThat(confirmed).isIn(values) + assertThat(confirmed).isAtLeast(FillConfig.FILL_MIN_UNITS) + } + + @Test + fun scrollingForward_selectsAHigherAmount() { + render(FillConfig.FILL_MIN_UNITS) + + compose.onNode(hasScrollAction()).performScrollToIndex(20) + compose.waitForIdle() + + val confirmed = pressNextAndCaptureAmount() + + assertThat(confirmed).isIn(values) + assertThat(confirmed).isGreaterThan(FillConfig.FILL_MIN_UNITS) + assertThat(confirmed).isAtMost(FillConfig.FILL_MAX_UNITS) + } + + @Test + fun scrollingBackToStart_selectsALowerAmount() { + render(FillConfig.FILL_MAX_UNITS) + + compose.onNode(hasScrollAction()).performScrollToIndex(0) + compose.waitForIdle() + + val confirmed = pressNextAndCaptureAmount() + + assertThat(confirmed).isIn(values) + assertThat(confirmed).isLessThan(FillConfig.FILL_MAX_UNITS) + assertThat(confirmed).isAtLeast(FillConfig.FILL_MIN_UNITS) + } + + @Test + fun scrollingToLastIndex_rendersMaximumRow() { + render(FillConfig.FILL_MIN_UNITS) + + compose.onNode(hasScrollAction()).performScrollToIndex(values.lastIndex) + compose.waitForIdle() + + compose.onNodeWithText("300").assertExists() + } + + @Test + fun scrollingToFirstIndex_rendersMinimumRow() { + render(FillConfig.FILL_MAX_UNITS) + + compose.onNode(hasScrollAction()).performScrollToIndex(0) + compose.waitForIdle() + + compose.onNodeWithText("50").assertExists() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/config/CarelevoConfigTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/config/CarelevoConfigTest.kt new file mode 100644 index 000000000000..6662be56f6c1 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/config/CarelevoConfigTest.kt @@ -0,0 +1,108 @@ +package app.aaps.pump.carelevo.config + +import com.google.common.truth.Truth.assertThat +import java.util.UUID +import org.junit.jupiter.api.Test + +/** + * Guards the Carelevo config constants — BLE UUIDs, reservoir fill limits, and the + * preference storage keys — against accidental drift. Values here are the single source + * of truth referenced across the driver. + */ +internal class CarelevoConfigTest { + + // ---------- BleEnvConfig ---------- + + @Test + fun `BleEnvConfig UUID constants hold the exact expected values`() { + assertThat(BleEnvConfig.BLE_CCC_DESCRIPTOR).isEqualTo("00002902-0000-1000-8000-00805f9b34fb") + assertThat(BleEnvConfig.BLE_SERVICE_UUID).isEqualTo("e1b40001-ffc4-4daa-a49b-1c92f99072ab") + assertThat(BleEnvConfig.BLE_TX_CHAR_UUID).isEqualTo("e1b40003-ffc4-4daa-a49b-1c92f99072ab") + assertThat(BleEnvConfig.BLE_RX_CHAR_UUID).isEqualTo("e1b40002-ffc4-4daa-a49b-1c92f99072ab") + } + + @Test + fun `BleEnvConfig UUID constants are well-formed UUIDs`() { + listOf( + BleEnvConfig.BLE_CCC_DESCRIPTOR, + BleEnvConfig.BLE_SERVICE_UUID, + BleEnvConfig.BLE_TX_CHAR_UUID, + BleEnvConfig.BLE_RX_CHAR_UUID + ).forEach { assertThat(UUID.fromString(it).toString()).isEqualTo(it) } + } + + @Test + fun `BleEnvConfig TX and RX characteristics differ from each other and the service`() { + assertThat(BleEnvConfig.BLE_TX_CHAR_UUID).isNotEqualTo(BleEnvConfig.BLE_RX_CHAR_UUID) + assertThat(BleEnvConfig.BLE_TX_CHAR_UUID).isNotEqualTo(BleEnvConfig.BLE_SERVICE_UUID) + assertThat(BleEnvConfig.BLE_RX_CHAR_UUID).isNotEqualTo(BleEnvConfig.BLE_SERVICE_UUID) + } + + // ---------- FillConfig ---------- + + @Test + fun `FillConfig fill limits hold the expected values`() { + assertThat(FillConfig.FILL_MIN_UNITS).isEqualTo(50) + assertThat(FillConfig.FILL_MAX_UNITS).isEqualTo(300) + assertThat(FillConfig.FILL_STEP_UNITS).isEqualTo(10) + } + + @Test + fun `FillConfig max stays in sync with the CAREMEDI maxReservoirReading of 300`() { + assertThat(FillConfig.FILL_MAX_UNITS).isEqualTo(300) + } + + @Test + fun `FillConfig min is below max and both are positive`() { + assertThat(FillConfig.FILL_MIN_UNITS).isGreaterThan(0) + assertThat(FillConfig.FILL_MIN_UNITS).isLessThan(FillConfig.FILL_MAX_UNITS) + } + + @Test + fun `FillConfig step evenly divides the fillable range and both bounds`() { + assertThat(FillConfig.FILL_STEP_UNITS).isGreaterThan(0) + assertThat((FillConfig.FILL_MAX_UNITS - FillConfig.FILL_MIN_UNITS) % FillConfig.FILL_STEP_UNITS).isEqualTo(0) + assertThat(FillConfig.FILL_MIN_UNITS % FillConfig.FILL_STEP_UNITS).isEqualTo(0) + assertThat(FillConfig.FILL_MAX_UNITS % FillConfig.FILL_STEP_UNITS).isEqualTo(0) + } + + // ---------- PrefEnvConfig ---------- + + @Test + fun `PrefEnvConfig keys hold the expected values`() { + assertThat(PrefEnvConfig.PATCH_INFO).isEqualTo("carelevo_patch_info") + assertThat(PrefEnvConfig.BASAL_INFUSION_INFO).isEqualTo("carelevo_basal_infusion_info") + assertThat(PrefEnvConfig.TEMP_BASAL_INFUSION_INFO).isEqualTo("carelevo_temp_basal_infusion_info") + assertThat(PrefEnvConfig.IMME_BOLUS_INFUSION_INFO).isEqualTo("carelevo_imme_bolus_infusion_info") + assertThat(PrefEnvConfig.EXTEND_BOLUS_INFUSION_INFO).isEqualTo("carelevo_extend_bolus_infusion_info") + assertThat(PrefEnvConfig.USER_SETTING_INFO).isEqualTo("carelevo_user_setting_info") + assertThat(PrefEnvConfig.CARELEVO_ALARM_INFO_LIST).isEqualTo("carelevo_alarm_info_list") + } + + @Test + fun `PrefEnvConfig keys are all carelevo-namespaced`() { + listOf( + PrefEnvConfig.PATCH_INFO, + PrefEnvConfig.BASAL_INFUSION_INFO, + PrefEnvConfig.TEMP_BASAL_INFUSION_INFO, + PrefEnvConfig.IMME_BOLUS_INFUSION_INFO, + PrefEnvConfig.EXTEND_BOLUS_INFUSION_INFO, + PrefEnvConfig.USER_SETTING_INFO, + PrefEnvConfig.CARELEVO_ALARM_INFO_LIST + ).forEach { assertThat(it).startsWith("carelevo_") } + } + + @Test + fun `PrefEnvConfig keys are unique`() { + val keys = listOf( + PrefEnvConfig.PATCH_INFO, + PrefEnvConfig.BASAL_INFUSION_INFO, + PrefEnvConfig.TEMP_BASAL_INFUSION_INFO, + PrefEnvConfig.IMME_BOLUS_INFUSION_INFO, + PrefEnvConfig.EXTEND_BOLUS_INFUSION_INFO, + PrefEnvConfig.USER_SETTING_INFO, + PrefEnvConfig.CARELEVO_ALARM_INFO_LIST + ) + assertThat(keys.toSet()).hasSize(keys.size) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoAlarmClearCoordinatorTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoAlarmClearCoordinatorTest.kt new file mode 100644 index 000000000000..cb36b1964709 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoAlarmClearCoordinatorTest.kt @@ -0,0 +1,191 @@ +package app.aaps.pump.carelevo.coordinator + +import app.aaps.core.data.pump.defs.PumpType +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.pump.carelevo.ble.CarelevoBleTransport +import app.aaps.pump.carelevo.command.CmdAlarmClear +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.type.AlarmCause +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.subjects.BehaviorSubject +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.Optional +import java.util.concurrent.atomic.AtomicInteger + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoAlarmClearCoordinatorTest { + + @Mock lateinit var aapsLogger: AAPSLogger + @Mock lateinit var commandQueue: CommandQueue + @Mock lateinit var carelevoPatch: CarelevoPatch + @Mock lateinit var transport: CarelevoBleTransport + @Mock lateinit var pumpSync: PumpSync + @Mock lateinit var dateUtil: DateUtil + + private lateinit var sut: CarelevoAlarmClearCoordinator + + private fun enactResult(success: Boolean): PumpEnactResult = mock().also { + whenever(it.success).thenReturn(success) + } + + private fun alarm(cause: AlarmCause = AlarmCause.ALARM_ALERT_OUT_OF_INSULIN): CarelevoAlarmInfo = + CarelevoAlarmInfo( + alarmId = "alarm-1", + alarmType = cause.alarmType, + cause = cause, + value = null, + createdAt = "2026-07-16T10:00:00", + updatedAt = "2026-07-16T10:00:00", + isAcknowledged = false + ) + + @BeforeEach + fun setUp() { + whenever(dateUtil.now()).thenReturn(1_000_000L) + whenever(carelevoPatch.patchInfo).thenReturn(BehaviorSubject.createDefault(Optional.empty())) + sut = CarelevoAlarmClearCoordinator(aapsLogger, commandQueue, carelevoPatch, transport, pumpSync, dateUtil) + } + + @Test + fun `clearAlarmOnPatch returns the queue result`() = runBlocking { + // Build the result mocks BEFORE stubbing — creating a stubbed mock inside thenReturn is + // "stubbing another mock before thenReturn completes" and trips UnfinishedStubbingException. + val ok = enactResult(true) + val fail = enactResult(false) + + whenever { commandQueue.customCommand(any()) }.thenReturn(ok) + assertThat(sut.clearAlarmOnPatch(alarm())).isTrue() + + whenever { commandQueue.customCommand(any()) }.thenReturn(fail) + assertThat(sut.clearAlarmOnPatch(alarm())).isFalse() + } + + @Test + fun `opMutex serializes two concurrent clear operations`(): Unit = runBlocking { + // First command call parks until released; a concurrent second clear must NOT reach the + // queue while the first holds the mutex — CommandQueue dedups by command class, so a + // parallel second alarm-clear would misread as a clear-failure and locally dismiss an + // alarm that was never cleared on the patch. + val firstStarted = CompletableDeferred() + val releaseFirst = CompletableDeferred() + val callCount = AtomicInteger(0) + val ok = enactResult(true) + whenever { commandQueue.customCommand(any()) }.thenAnswer { + if (callCount.incrementAndGet() == 1) { + firstStarted.complete(Unit) + runBlocking { releaseFirst.await() } + } + ok + } + + val first = async(Dispatchers.Default) { sut.clearAlarmOnPatch(alarm()) } + firstStarted.await() + val second = async(Dispatchers.Default) { sut.discardOnAlarm(alarm(AlarmCause.ALARM_WARNING_PUMP_CLOGGED)) } + // Give the second op every chance to (incorrectly) enter the queue while the first holds the mutex. + delay(300) + assertThat(callCount.get()).isEqualTo(1) + + releaseFirst.complete(Unit) + assertThat(first.await()).isTrue() + assertThat(second.await()).isTrue() + assertThat(callCount.get()).isEqualTo(2) + } + + @Test + fun `resumeInfusion does not record a TBR stop when none is expected to be running`() = runBlocking { + val ok = enactResult(true) + whenever { commandQueue.customCommand(any()) }.thenReturn(ok) + whenever { pumpSync.expectedPumpState() }.thenReturn( + PumpSync.PumpState(temporaryBasal = null, extendedBolus = null, bolus = null, profile = null, serialNumber = "SN") + ) + + assertThat(sut.resumeInfusion()).isTrue() + verifyBlocking(pumpSync, never()) { syncStopTemporaryBasalWithPumpId(any(), any(), any(), any(), any()) } + } + + @Test + fun `resumeInfusion records the TBR stop when one is running`() = runBlocking { + val ok = enactResult(true) + whenever { commandQueue.customCommand(any()) }.thenReturn(ok) + val tbr = PumpSync.PumpState.TemporaryBasal( + timestamp = 0L, duration = 60_000L, rate = 0.0, isAbsolute = true, + type = PumpSync.TemporaryBasalType.PUMP_SUSPEND, id = 1L, pumpId = 0L, + pumpType = PumpType.CAREMEDI_CARELEVO, pumpSerial = "SN" + ) + whenever { pumpSync.expectedPumpState() }.thenReturn( + PumpSync.PumpState(temporaryBasal = tbr, extendedBolus = null, bolus = null, profile = null, serialNumber = "SN") + ) + + assertThat(sut.resumeInfusion()).isTrue() + verifyBlocking(pumpSync) { syncStopTemporaryBasalWithPumpId(any(), any(), any(), any(), any()) } + } + + @Test + fun `resumeInfusion does not touch pumpSync when the resume command fails`() = runBlocking { + val fail = enactResult(false) + whenever { commandQueue.customCommand(any()) }.thenReturn(fail) + + assertThat(sut.resumeInfusion()).isFalse() + verifyBlocking(pumpSync, never()) { syncStopTemporaryBasalWithPumpId(any(), any(), any(), any(), any()) } + } + + @Test + fun `forceQuitTeardown flushes state and completes even when the unbond throws`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn("aa:bb:cc:dd:ee:ff") + whenever(transport.adapter).thenThrow(RuntimeException("no adapter")) + var completed = false + + sut.forceQuitTeardown { completed = true } + + assertThat(completed).isTrue() + verify(carelevoPatch).flushPatchInformation() + } + + @Test + fun `isPatchReachable requires both a stored address and enabled bluetooth`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + assertThat(sut.isPatchReachable()).isFalse() + + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn("aa:bb:cc:dd:ee:ff") + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(false) + assertThat(sut.isPatchReachable()).isFalse() + + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + assertThat(sut.isPatchReachable()).isTrue() + } + + @Test + fun `clearAlarmOnPatch routes through the queue as a custom command`() = runBlocking { + val ok = enactResult(true) + whenever { commandQueue.customCommand(any()) }.thenReturn(ok) + + sut.clearAlarmOnPatch(alarm()) + + verifyBlocking(commandQueue) { customCommand(any()) } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoBasalProfileUpdateCoordinatorTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoBasalProfileUpdateCoordinatorTest.kt new file mode 100644 index 000000000000..68635ceab7f0 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoBasalProfileUpdateCoordinatorTest.kt @@ -0,0 +1,351 @@ +package app.aaps.pump.carelevo.coordinator + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.profile.Profile +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.pump.carelevo.ble.CarelevoBleSession +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoExtendBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoTempBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.usecase.basal.CarelevoSetBasalProgramUseCase +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.subjects.BehaviorSubject +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.Optional +import java.util.concurrent.atomic.AtomicInteger +import javax.inject.Provider + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoBasalProfileUpdateCoordinatorTest { + + @Mock lateinit var aapsLogger: AAPSLogger + @Mock lateinit var rh: ResourceHelper + @Mock lateinit var carelevoPatch: CarelevoPatch + @Mock lateinit var bleSession: CarelevoBleSession + @Mock lateinit var setBasalProgramUseCase: CarelevoSetBasalProgramUseCase + + private lateinit var sut: CarelevoBasalProfileUpdateCoordinator + private lateinit var profile: Profile + + private val pumpEnactResultProvider = Provider { FakePumpEnactResult() } + + private val programs = List(3) { List(8) { 1.0 } } + + private fun plan() = CarelevoSetBasalProgramUseCase.BasalProgramPlan(programs = programs, segments = emptyList()) + + private fun enact(success: Boolean): PumpEnactResult = FakePumpEnactResult().success(success).enacted(success) + + private fun basalInfusion() = CarelevoBasalInfusionInfoDomainModel( + infusionId = "basal-1", address = ADDRESS, mode = 1, segments = emptyList(), isStop = false + ) + + private fun extendBolusInfusion() = CarelevoExtendBolusInfusionInfoDomainModel( + infusionId = "ext-1", address = ADDRESS, mode = 5 + ) + + private fun tempBasalInfusion() = CarelevoTempBasalInfusionInfoDomainModel( + infusionId = "tbr-1", address = ADDRESS, mode = 2 + ) + + private fun stubInfusionInfo(model: CarelevoInfusionInfoDomainModel?) { + val subject = + if (model != null) BehaviorSubject.createDefault(Optional.of(model)) + else BehaviorSubject.create>() + whenever(carelevoPatch.infusionInfo).thenReturn(subject) + } + + @BeforeEach + fun setUp() { + whenever(rh.gs(any())).thenReturn("Mocked") + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(ADDRESS) + whenever(setBasalProgramUseCase.buildBasalProgramPlan(any())).thenReturn(plan()) + whenever(setBasalProgramUseCase.persistBasalProgram(any())).thenReturn(true) + whenever { bleSession.runBasalProgram(any(), any(), any()) }.thenReturn(true) + // Default: no running infusion → basalInfusionInfo == null → SET (0x13) mode, no cancels. + stubInfusionInfo(CarelevoInfusionInfoDomainModel()) + + profile = mock() + + sut = CarelevoBasalProfileUpdateCoordinator( + aapsLogger = aapsLogger, + rh = rh, + pumpEnactResultProvider = pumpEnactResultProvider, + carelevoPatch = carelevoPatch, + bleSession = bleSession, + setBasalProgramUseCase = setBasalProgramUseCase + ) + } + + // --- success paths ----------------------------------------------------------------------------- + + @Test + fun `set mode success uses the set opcode and marks enacted`() { + var updated: Profile? = null + + val result = sut.updateBasalProfile(profile, { enact(true) }, { enact(true) }, { updated = it }) + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + assertThat(updated).isSameInstanceAs(profile) + verifyBlocking(bleSession) { runBasalProgram(eq(ADDRESS), eq(programs), eq(false)) } + verify(setBasalProgramUseCase).persistBasalProgram(any()) + } + + @Test + fun `update mode uses the change opcode when a basal program already exists`() { + stubInfusionInfo(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion())) + + val result = sut.updateBasalProfile(profile, { enact(true) }, { enact(true) }, { }) + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + verifyBlocking(bleSession) { runBasalProgram(eq(ADDRESS), any(), eq(true)) } + } + + @Test + fun `plan is built from the profile and forwarded to the program write`() { + sut.updateBasalProfile(profile, { enact(true) }, { enact(true) }, { }) + + verify(setBasalProgramUseCase).buildBasalProgramPlan(profile) + verifyBlocking(bleSession) { runBasalProgram(any(), eq(programs), any()) } + } + + @Test + fun `null infusion info is treated as set mode with no cancels`() { + stubInfusionInfo(null) + val extendCalls = AtomicInteger(0) + val tempCalls = AtomicInteger(0) + + val result = sut.updateBasalProfile( + profile, + { extendCalls.incrementAndGet(); enact(true) }, + { tempCalls.incrementAndGet(); enact(true) }, + { } + ) + + assertThat(result.success).isTrue() + assertThat(extendCalls.get()).isEqualTo(0) + assertThat(tempCalls.get()).isEqualTo(0) + verifyBlocking(bleSession) { runBasalProgram(any(), any(), eq(false)) } + } + + @Test + fun `no running infusion skips both cancel callbacks`() { + val extendCalls = AtomicInteger(0) + val tempCalls = AtomicInteger(0) + + val result = sut.updateBasalProfile( + profile, + { extendCalls.incrementAndGet(); enact(true) }, + { tempCalls.incrementAndGet(); enact(true) }, + { } + ) + + assertThat(result.success).isTrue() + assertThat(extendCalls.get()).isEqualTo(0) + assertThat(tempCalls.get()).isEqualTo(0) + } + + @Test + fun `running extended bolus is cancelled before the program write`() { + stubInfusionInfo(CarelevoInfusionInfoDomainModel(extendBolusInfusionInfo = extendBolusInfusion())) + val extendCalls = AtomicInteger(0) + + val result = sut.updateBasalProfile(profile, { extendCalls.incrementAndGet(); enact(true) }, { enact(true) }, { }) + + assertThat(result.success).isTrue() + assertThat(extendCalls.get()).isEqualTo(1) + verifyBlocking(bleSession) { runBasalProgram(any(), any(), any()) } + } + + @Test + fun `running temp basal is cancelled before the program write`() { + stubInfusionInfo(CarelevoInfusionInfoDomainModel(tempBasalInfusionInfo = tempBasalInfusion())) + val tempCalls = AtomicInteger(0) + + val result = sut.updateBasalProfile(profile, { enact(true) }, { tempCalls.incrementAndGet(); enact(true) }, { }) + + assertThat(result.success).isTrue() + assertThat(tempCalls.get()).isEqualTo(1) + verifyBlocking(bleSession) { runBasalProgram(any(), any(), any()) } + } + + @Test + fun `both extended bolus and temp basal are cancelled when both are running`() { + stubInfusionInfo( + CarelevoInfusionInfoDomainModel( + extendBolusInfusionInfo = extendBolusInfusion(), + tempBasalInfusionInfo = tempBasalInfusion() + ) + ) + val extendCalls = AtomicInteger(0) + val tempCalls = AtomicInteger(0) + + val result = sut.updateBasalProfile( + profile, + { extendCalls.incrementAndGet(); enact(true) }, + { tempCalls.incrementAndGet(); enact(true) }, + { } + ) + + assertThat(result.success).isTrue() + assertThat(extendCalls.get()).isEqualTo(1) + assertThat(tempCalls.get()).isEqualTo(1) + verifyBlocking(bleSession) { runBasalProgram(any(), any(), any()) } + } + + // --- failure / guard paths --------------------------------------------------------------------- + + @Test + fun `missing patch address fails without enacting and without updating the profile`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + var updated: Profile? = null + + val result = sut.updateBasalProfile(profile, { enact(true) }, { enact(true) }, { updated = it }) + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(updated).isNull() + } + + @Test + fun `rejected program write fails and does not update the profile`() { + whenever { bleSession.runBasalProgram(any(), any(), any()) }.thenReturn(false) + var updated: Profile? = null + + val result = sut.updateBasalProfile(profile, { enact(true) }, { enact(true) }, { updated = it }) + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(updated).isNull() + verify(setBasalProgramUseCase, never()).persistBasalProgram(any()) + } + + @Test + fun `program write exception is mapped to a failed result`() { + whenever { bleSession.runBasalProgram(any(), any(), any()) }.thenAnswer { throw RuntimeException("ble boom") } + var updated: Profile? = null + + val result = sut.updateBasalProfile(profile, { enact(true) }, { enact(true) }, { updated = it }) + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(updated).isNull() + } + + @Test + fun `persist failure fails after a successful write and does not update the profile`() { + whenever(setBasalProgramUseCase.persistBasalProgram(any())).thenReturn(false) + var updated: Profile? = null + + val result = sut.updateBasalProfile(profile, { enact(true) }, { enact(true) }, { updated = it }) + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(updated).isNull() + verifyBlocking(bleSession) { runBasalProgram(any(), any(), any()) } + } + + @Test + fun `extended bolus cancel rejection fails before any program write`() { + stubInfusionInfo(CarelevoInfusionInfoDomainModel(extendBolusInfusionInfo = extendBolusInfusion())) + + val result = sut.updateBasalProfile(profile, { enact(false) }, { enact(true) }, { }) + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + verifyBlocking(bleSession, never()) { runBasalProgram(any(), any(), any()) } + } + + @Test + fun `temp basal cancel rejection fails before any program write`() { + stubInfusionInfo(CarelevoInfusionInfoDomainModel(tempBasalInfusionInfo = tempBasalInfusion())) + + val result = sut.updateBasalProfile(profile, { enact(true) }, { enact(false) }, { }) + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + verifyBlocking(bleSession, never()) { runBasalProgram(any(), any(), any()) } + } + + // --- debounce ---------------------------------------------------------------------------------- + + @Test + fun `repeated call within the debounce window is skipped after a success`() { + var updateCount = 0 + val first = sut.updateBasalProfile(profile, { enact(true) }, { enact(true) }, { updateCount++ }) + assertThat(first.success).isTrue() + assertThat(first.enacted).isTrue() + + val second = sut.updateBasalProfile(profile, { enact(true) }, { enact(true) }, { updateCount++ }) + + // Benign debounce: success=true but NOT enacted, and the pump work happened only once. + assertThat(second.success).isTrue() + assertThat(second.enacted).isFalse() + assertThat(updateCount).isEqualTo(1) + verifyBlocking(bleSession, times(1)) { runBasalProgram(any(), any(), any()) } + } + + @Test + fun `repeated call within the debounce window is skipped after a failure`() { + whenever { bleSession.runBasalProgram(any(), any(), any()) }.thenReturn(false) + + val first = sut.updateBasalProfile(profile, { enact(true) }, { enact(true) }, { }) + assertThat(first.success).isFalse() + + val second = sut.updateBasalProfile(profile, { enact(true) }, { enact(true) }, { }) + + assertThat(second.success).isTrue() + assertThat(second.enacted).isFalse() + verifyBlocking(bleSession, times(1)) { runBasalProgram(any(), any(), any()) } + } + + private companion object { + + private const val ADDRESS = "AA:BB:CC:DD:EE:FF" + } + + private class FakePumpEnactResult : PumpEnactResult { + + override var success: Boolean = false + override var enacted: Boolean = false + override var comment: String = "" + override var duration: Int = -1 + override var absolute: Double = -1.0 + override var percent: Int = -1 + override var isPercent: Boolean = false + override var isTempCancel: Boolean = false + override var bolusDelivered: Double = 0.0 + override var queued: Boolean = false + + override fun success(success: Boolean): PumpEnactResult = apply { this.success = success } + override fun enacted(enacted: Boolean): PumpEnactResult = apply { this.enacted = enacted } + override fun comment(comment: String): PumpEnactResult = apply { this.comment = comment } + override fun comment(comment: Int): PumpEnactResult = apply { this.comment = comment.toString() } + override fun duration(duration: Int): PumpEnactResult = apply { this.duration = duration } + override fun absolute(absolute: Double): PumpEnactResult = apply { this.absolute = absolute } + override fun percent(percent: Int): PumpEnactResult = apply { this.percent = percent } + override fun isPercent(isPercent: Boolean): PumpEnactResult = apply { this.isPercent = isPercent } + override fun isTempCancel(isTempCancel: Boolean): PumpEnactResult = apply { this.isTempCancel = isTempCancel } + override fun bolusDelivered(bolusDelivered: Double): PumpEnactResult = apply { this.bolusDelivered = bolusDelivered } + override fun queued(queued: Boolean): PumpEnactResult = apply { this.queued = queued } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoBolusCoordinatorTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoBolusCoordinatorTest.kt new file mode 100644 index 000000000000..b0daead36442 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoBolusCoordinatorTest.kt @@ -0,0 +1,387 @@ +package app.aaps.pump.carelevo.coordinator + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.pump.BolusProgressData +import app.aaps.core.interfaces.pump.BolusProgressState +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.pump.carelevo.ble.CarelevoBleSession +import app.aaps.pump.carelevo.ble.commands.BolusCancelCommand +import app.aaps.pump.carelevo.ble.commands.BolusCancelResponse +import app.aaps.pump.carelevo.ble.commands.ExtendBolusCancelCommand +import app.aaps.pump.carelevo.ble.commands.ExtendBolusCancelResponse +import app.aaps.pump.carelevo.ble.commands.ExtendBolusCommand +import app.aaps.pump.carelevo.ble.commands.ExtendBolusResponse +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoCancelExtendBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoCancelImmeBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoFinishImmeBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoStartExtendBolusInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.bolus.CarelevoStartImmeBolusInfusionUseCase +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.isA +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.concurrent.atomic.AtomicInteger +import javax.inject.Provider + +/** + * Direct unit tests for [CarelevoBolusCoordinator] targeting the branches NOT exercised through + * `CarelevoPumpPluginBolusTest`: the immediate-bolus out-of-band cancel (whole path + timeout-only + * retry), the extended-bolus guard/failure branches, and the pure-extended `immediateDose == 0` + * wire encoding. + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoBolusCoordinatorTest { + + @Mock lateinit var aapsLogger: AAPSLogger + @Mock lateinit var rh: ResourceHelper + @Mock lateinit var dateUtil: DateUtil + @Mock lateinit var bolusProgressData: BolusProgressData + @Mock lateinit var pumpSync: PumpSync + @Mock lateinit var aapsSchedulers: AapsSchedulers + @Mock lateinit var carelevoPatch: CarelevoPatch + @Mock lateinit var bleSession: CarelevoBleSession + @Mock lateinit var startImmeBolusInfusionUseCase: CarelevoStartImmeBolusInfusionUseCase + @Mock lateinit var finishImmeBolusInfusionUseCase: CarelevoFinishImmeBolusInfusionUseCase + @Mock lateinit var cancelImmeBolusInfusionUseCase: CarelevoCancelImmeBolusInfusionUseCase + @Mock lateinit var startExtendBolusInfusionUseCase: CarelevoStartExtendBolusInfusionUseCase + @Mock lateinit var cancelExtendBolusInfusionUseCase: CarelevoCancelExtendBolusInfusionUseCase + + private lateinit var sut: CarelevoBolusCoordinator + + private val serial = "CARELEVO-SN" + private val address = "AA:BB:CC:DD:EE:FF" + + /** Concrete [PumpEnactResult] the provider hands out (mirrors the test-base fake). */ + private class FakePumpEnactResult : PumpEnactResult { + + override var success: Boolean = false + override var enacted: Boolean = false + override var comment: String = "" + override var duration: Int = -1 + override var absolute: Double = -1.0 + override var percent: Int = -1 + override var isPercent: Boolean = false + override var isTempCancel: Boolean = false + override var bolusDelivered: Double = 0.0 + override var queued: Boolean = false + + override fun success(success: Boolean): PumpEnactResult = apply { this.success = success } + override fun enacted(enacted: Boolean): PumpEnactResult = apply { this.enacted = enacted } + override fun comment(comment: String): PumpEnactResult = apply { this.comment = comment } + override fun comment(comment: Int): PumpEnactResult = apply { this.comment = comment.toString() } + override fun duration(duration: Int): PumpEnactResult = apply { this.duration = duration } + override fun absolute(absolute: Double): PumpEnactResult = apply { this.absolute = absolute } + override fun percent(percent: Int): PumpEnactResult = apply { this.percent = percent } + override fun isPercent(isPercent: Boolean): PumpEnactResult = apply { this.isPercent = isPercent } + override fun isTempCancel(isTempCancel: Boolean): PumpEnactResult = apply { this.isTempCancel = isTempCancel } + override fun bolusDelivered(bolusDelivered: Double): PumpEnactResult = apply { this.bolusDelivered = bolusDelivered } + override fun queued(queued: Boolean): PumpEnactResult = apply { this.queued = queued } + } + + /** Throws a genuine [kotlinx.coroutines.TimeoutCancellationException] (no public ctor otherwise). */ + private fun answerTimeout(): Any = runBlocking { withTimeout(1) { delay(10_000) } } + + @BeforeEach + fun setUp() { + whenever(dateUtil.now()).thenReturn(1_000_000L) + whenever(rh.gs(any())).thenReturn("Mocked") + whenever(rh.gs(any(), any())).thenReturn("Mocked") + + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(address) + + // Progress state is read (`.value?.percent`) on the immediate-cancel success path. + whenever(bolusProgressData.state).thenReturn(MutableStateFlow(null)) + + // Happy-path session defaults; individual tests override per command type. + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenReturn(ExtendBolusResponse(resultCode = 0, expectedTimeSeconds = 60)) + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenReturn(ExtendBolusCancelResponse(resultCode = 0, infusedAmount = 0.5)) + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenReturn(BolusCancelResponse(resultCode = 0, infusedAmount = 0.3)) + + whenever(startExtendBolusInfusionUseCase.persistExtendBolusStarted(any(), any(), any())).thenReturn(true) + whenever(cancelExtendBolusInfusionUseCase.persistExtendBolusCancelled()).thenReturn(true) + whenever(cancelImmeBolusInfusionUseCase.persistImmeBolusCancelled()).thenReturn(true) + + val provider = Provider { FakePumpEnactResult() } + sut = CarelevoBolusCoordinator( + aapsLogger = aapsLogger, + rh = rh, + dateUtil = dateUtil, + bolusProgressData = bolusProgressData, + pumpSync = pumpSync, + aapsSchedulers = aapsSchedulers, + pumpEnactResultProvider = provider, + carelevoPatch = carelevoPatch, + bleSession = bleSession, + startImmeBolusInfusionUseCase = startImmeBolusInfusionUseCase, + finishImmeBolusInfusionUseCase = finishImmeBolusInfusionUseCase, + cancelImmeBolusInfusionUseCase = cancelImmeBolusInfusionUseCase, + startExtendBolusInfusionUseCase = startExtendBolusInfusionUseCase, + cancelExtendBolusInfusionUseCase = cancelExtendBolusInfusionUseCase + ) + } + + /** Reflectively seeds the private `bolusExpectMs` so the cancel-retry budget is > 0. */ + private fun seedBolusExpectMs(value: Long) { + val field = CarelevoBolusCoordinator::class.java.getDeclaredField("bolusExpectMs") + field.isAccessible = true + field.setLong(sut, value) + } + + // --------------------------------------------------------------------------------------------- + // setExtendedBolus — guards + failure + pure-extended encoding + // --------------------------------------------------------------------------------------------- + + @Test + fun `setExtendedBolus rejects a non-positive insulin`() { + assertThrows(IllegalArgumentException::class.java) { + sut.setExtendedBolus(insulin = 0.0, durationInMinutes = 30, serialNumber = serial) + } + } + + @Test + fun `setExtendedBolus rejects a non-positive duration`() { + assertThrows(IllegalArgumentException::class.java) { + sut.setExtendedBolus(insulin = 1.0, durationInMinutes = 0, serialNumber = serial) + } + } + + @Test + fun `setExtendedBolus returns a not-enacted result when bluetooth is disabled`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(false) + + val result = sut.setExtendedBolus(insulin = 1.0, durationInMinutes = 30, serialNumber = serial) + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + verifyBlocking(bleSession, never()) { runSingle(any(), isA(), any()) } + } + + @Test + fun `setExtendedBolus fails with no patch address when the address is missing`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + + val result = sut.setExtendedBolus(insulin = 1.0, durationInMinutes = 30, serialNumber = serial) + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(result.comment).isEqualTo("no patch address") + verifyBlocking(bleSession, never()) { runSingle(any(), isA(), any()) } + } + + @Test + fun `setExtendedBolus encodes a pure extended dose with immediateDose zero`() { + var captured: ExtendBolusCommand? = null + whenever { bleSession.runSingle(any(), isA(), any()) }.thenAnswer { inv -> + captured = inv.getArgument(1) as ExtendBolusCommand + ExtendBolusResponse(resultCode = 0, expectedTimeSeconds = 60) + } + + // insulin 3.0 U over 90 min → speed 3.0 / (90/60) = 2.0 U/h, hour=1, min=30. + val result = sut.setExtendedBolus(insulin = 3.0, durationInMinutes = 90, serialNumber = serial) + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + val bytes = captured!!.encode() + assertThat(bytes[0]).isEqualTo(0x25.toByte()) // opcode + assertThat(bytes[1]).isEqualTo(0.toByte()) // immediateDose whole == 0 + assertThat(bytes[2]).isEqualTo(0.toByte()) // immediateDose centi == 0 + assertThat(bytes[3]).isEqualTo(2.toByte()) // extendedSpeed whole == 2 + assertThat(bytes[4]).isEqualTo(0.toByte()) // extendedSpeed centi == 0 + assertThat(bytes[5]).isEqualTo(1.toByte()) // hour + assertThat(bytes[6]).isEqualTo(30.toByte()) // min + } + + @Test + fun `setExtendedBolus fails when the session throws`() { + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenAnswer { throw IllegalStateException("boom") } + + val result = sut.setExtendedBolus(insulin = 1.0, durationInMinutes = 30, serialNumber = serial) + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + verifyBlocking(pumpSync, never()) { syncExtendedBolusWithPumpId(any(), any(), any(), any(), any(), any(), any()) } + } + + // --------------------------------------------------------------------------------------------- + // cancelExtendedBolus — guards + reject + success + // --------------------------------------------------------------------------------------------- + + @Test + fun `cancelExtendedBolus returns a not-enacted result when bluetooth is disabled`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(false) + + val result = sut.cancelExtendedBolus(serialNumber = serial) {} + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + verifyBlocking(bleSession, never()) { runSingle(any(), isA(), any()) } + } + + @Test + fun `cancelExtendedBolus fails with no patch address when the address is missing`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + + val result = sut.cancelExtendedBolus(serialNumber = serial) {} + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(result.comment).isEqualTo("no patch address") + } + + @Test + fun `cancelExtendedBolus fails when the pump rejects the command`() { + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenReturn(ExtendBolusCancelResponse(resultCode = 1, infusedAmount = 0.0)) + var lastUpdated = false + + val result = sut.cancelExtendedBolus(serialNumber = serial) { lastUpdated = true } + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(lastUpdated).isFalse() + verifyBlocking(pumpSync, never()) { syncStopExtendedBolusWithPumpId(any(), any(), any(), any()) } + } + + @Test + fun `cancelExtendedBolus success invokes onLastDataUpdated, records the stop and flags temp-cancel`() { + var lastUpdated = false + + val result = sut.cancelExtendedBolus(serialNumber = serial) { lastUpdated = true } + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + assertThat(result.isTempCancel).isTrue() + assertThat(lastUpdated).isTrue() + verifyBlocking(pumpSync) { syncStopExtendedBolusWithPumpId(any(), any(), any(), any()) } + } + + // --------------------------------------------------------------------------------------------- + // cancelImmediateBolus — whole out-of-band cancel path + timeout-only retry + // --------------------------------------------------------------------------------------------- + + @Test + fun `cancelImmediateBolus does nothing when there is no patch address`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + var lastUpdated = false + + sut.cancelImmediateBolus(serialNumber = serial) { lastUpdated = true } + + assertThat(lastUpdated).isFalse() + verifyBlocking(bleSession, never()) { runSingle(any(), isA(), any()) } + verifyBlocking(pumpSync, never()) { syncBolusWithPumpId(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `cancelImmediateBolus success records the infused amount and syncs the bolus`() { + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenReturn(BolusCancelResponse(resultCode = 0, infusedAmount = 0.42)) + var lastUpdated = false + + sut.cancelImmediateBolus(serialNumber = serial) { lastUpdated = true } + + assertThat(lastUpdated).isTrue() + verifyBlocking(cancelImmeBolusInfusionUseCase) { persistImmeBolusCancelled() } + verifyBlocking(pumpSync) { syncBolusWithPumpId(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `cancelImmediateBolus records the bolus in pumpSync even when the local persist fails`() { + whenever(cancelImmeBolusInfusionUseCase.persistImmeBolusCancelled()).thenReturn(false) + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenReturn(BolusCancelResponse(resultCode = 0, infusedAmount = 0.42)) + + sut.cancelImmediateBolus(serialNumber = serial) {} + + verifyBlocking(pumpSync) { syncBolusWithPumpId(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `cancelImmediateBolus does not sync when the pump rejects the cancel`() { + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenReturn(BolusCancelResponse(resultCode = 1, infusedAmount = 0.0)) + var lastUpdated = false + + sut.cancelImmediateBolus(serialNumber = serial) { lastUpdated = true } + + assertThat(lastUpdated).isFalse() + verifyBlocking(cancelImmeBolusInfusionUseCase, never()) { persistImmeBolusCancelled() } + verifyBlocking(pumpSync, never()) { syncBolusWithPumpId(any(), any(), any(), any(), any(), any()) } + verifyBlocking(bleSession, times(1)) { runSingle(any(), isA(), any()) } + } + + @Test + fun `cancelImmediateBolus gives up without retry on a non-timeout error`() { + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenAnswer { throw IllegalStateException("connect refused") } + + sut.cancelImmediateBolus(serialNumber = serial) {} + + verifyBlocking(bleSession, times(1)) { runSingle(any(), isA(), any()) } + verifyBlocking(pumpSync, never()) { syncBolusWithPumpId(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `cancelImmediateBolus does not retry a timeout when no retries are budgeted`() { + // bolusExpectMs defaults to 0 → calculateMaxRetry yields 0 → a single attempt only. + whenever { bleSession.runSingle(any(), isA(), any()) }.thenAnswer { answerTimeout() } + + sut.cancelImmediateBolus(serialNumber = serial) {} + + verifyBlocking(bleSession, times(1)) { runSingle(any(), isA(), any()) } + verifyBlocking(pumpSync, never()) { syncBolusWithPumpId(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `cancelImmediateBolus retries a timed-out attempt then gives up after exhausting the budget`() { + seedBolusExpectMs(30_000L) // → maxRetry capped to 1 → two attempts total + whenever { bleSession.runSingle(any(), isA(), any()) }.thenAnswer { answerTimeout() } + + sut.cancelImmediateBolus(serialNumber = serial) {} + + verifyBlocking(bleSession, times(2)) { runSingle(any(), isA(), any()) } + verifyBlocking(pumpSync, never()) { syncBolusWithPumpId(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `cancelImmediateBolus retries a timed-out attempt and succeeds on the second try`() { + seedBolusExpectMs(30_000L) // → maxRetry capped to 1 → a retry is allowed + val calls = AtomicInteger(0) + whenever { bleSession.runSingle(any(), isA(), any()) }.thenAnswer { + if (calls.getAndIncrement() == 0) answerTimeout() + else BolusCancelResponse(resultCode = 0, infusedAmount = 0.4) + } + var lastUpdated = false + + sut.cancelImmediateBolus(serialNumber = serial) { lastUpdated = true } + + assertThat(lastUpdated).isTrue() + verifyBlocking(bleSession, times(2)) { runSingle(any(), isA(), any()) } + verifyBlocking(pumpSync) { syncBolusWithPumpId(any(), any(), any(), any(), any(), any()) } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoConnectionCoordinatorTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoConnectionCoordinatorTest.kt new file mode 100644 index 000000000000..77c237d98ae2 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoConnectionCoordinatorTest.kt @@ -0,0 +1,150 @@ +package app.aaps.pump.carelevo.coordinator + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.pump.carelevo.ble.CarelevoBleSession +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.subjects.BehaviorSubject +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.Optional + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoConnectionCoordinatorTest { + + @Mock lateinit var aapsLogger: AAPSLogger + @Mock lateinit var carelevoPatch: CarelevoPatch + @Mock lateinit var bleSession: CarelevoBleSession + + private lateinit var sut: CarelevoConnectionCoordinator + + private fun patchInfo( + mode: Int? = null, + runningMinutes: Int? = null, + pumpState: Int? = null + ): CarelevoPatchInfoDomainModel = + CarelevoPatchInfoDomainModel( + address = "AA:BB:CC:DD:EE:FF", + mode = mode, + runningMinutes = runningMinutes, + pumpState = pumpState + ) + + private fun stubPatchInfo(subject: BehaviorSubject>) { + whenever(carelevoPatch.patchInfo).thenReturn(subject) + } + + @BeforeEach + fun setUp() { + sut = CarelevoConnectionCoordinator(aapsLogger, carelevoPatch, bleSession) + } + + // ---- isInitialized ---- + + @Test + fun `isInitialized false when the patch subject has no value yet`() { + // BehaviorSubject with no default → patchInfo.value is null → early return false. + stubPatchInfo(BehaviorSubject.create>()) + assertThat(sut.isInitialized()).isFalse() + } + + @Test + fun `isInitialized false when the patch info is empty`() { + stubPatchInfo(BehaviorSubject.createDefault(Optional.empty())) + assertThat(sut.isInitialized()).isFalse() + } + + @Test + fun `isInitialized false when mode, runningMinutes and pumpState are all null`() { + stubPatchInfo(BehaviorSubject.createDefault(Optional.of(patchInfo()))) + assertThat(sut.isInitialized()).isFalse() + } + + @Test + fun `isInitialized true when mode is set`() { + stubPatchInfo(BehaviorSubject.createDefault(Optional.of(patchInfo(mode = 1)))) + assertThat(sut.isInitialized()).isTrue() + } + + @Test + fun `isInitialized true when only runningMinutes is set`() { + stubPatchInfo(BehaviorSubject.createDefault(Optional.of(patchInfo(runningMinutes = 100)))) + assertThat(sut.isInitialized()).isTrue() + } + + @Test + fun `isInitialized true when only pumpState is set`() { + stubPatchInfo(BehaviorSubject.createDefault(Optional.of(patchInfo(pumpState = 0)))) + assertThat(sut.isInitialized()).isTrue() + } + + // ---- isConnected / isConnecting ---- + + @Test + fun `isConnected is true pre-activation even when the link is down`() { + // Not initialized (empty patch info) → forced true so the queue never dials a missing device. + stubPatchInfo(BehaviorSubject.createDefault(Optional.empty())) + whenever(bleSession.connected).thenReturn(MutableStateFlow(false)) + assertThat(sut.isConnected()).isTrue() + } + + @Test + fun `isConnected reflects a down held link once activated`() { + stubPatchInfo(BehaviorSubject.createDefault(Optional.of(patchInfo(mode = 1)))) + whenever(bleSession.connected).thenReturn(MutableStateFlow(false)) + assertThat(sut.isConnected()).isFalse() + } + + @Test + fun `isConnected reflects an up held link once activated`() { + stubPatchInfo(BehaviorSubject.createDefault(Optional.of(patchInfo(mode = 1)))) + whenever(bleSession.connected).thenReturn(MutableStateFlow(true)) + assertThat(sut.isConnected()).isTrue() + } + + @Test + fun `isConnecting delegates to the session`() { + whenever(bleSession.isConnecting).thenReturn(MutableStateFlow(true)) + assertThat(sut.isConnecting()).isTrue() + } + + // ---- connect / disconnect / stopConnecting drive the held link ---- + + @Test + fun `connect fires one attempt against the stored patch MAC`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn("AA:BB:CC:DD:EE:FF") + sut.connect("bootstrap") + verify(bleSession).requestConnect("AA:BB:CC:DD:EE:FF", "bootstrap") + } + + @Test + fun `connect no-ops when no patch address is known`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + sut.connect("bootstrap") + verify(bleSession, never()).requestConnect(any(), any()) + } + + @Test + fun `disconnect closes the held link`() { + sut.disconnect("shutdown") + verify(bleSession).requestDisconnect("shutdown") + } + + @Test + fun `stopConnecting closes the held link`() { + sut.stopConnecting() + verify(bleSession).requestDisconnect("stopConnecting") + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoSettingsCoordinatorTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoSettingsCoordinatorTest.kt new file mode 100644 index 000000000000..3a7a5bc7a6da --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoSettingsCoordinatorTest.kt @@ -0,0 +1,84 @@ +package app.aaps.pump.carelevo.coordinator + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.result.ResultSuccess +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import app.aaps.pump.carelevo.domain.usecase.userSetting.CarelevoDeleteUserSettingInfoUseCase +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoSettingsCoordinatorTest { + + @Mock lateinit var aapsLogger: AAPSLogger + @Mock lateinit var aapsSchedulers: AapsSchedulers + @Mock lateinit var deleteUserSettingInfoUseCase: CarelevoDeleteUserSettingInfoUseCase + + private lateinit var sut: CarelevoSettingsCoordinator + private lateinit var disposable: CompositeDisposable + + private fun stubDeleteResult(result: ResponseResult) { + whenever(deleteUserSettingInfoUseCase.execute()).thenReturn(Single.just(result)) + } + + @BeforeEach + fun setUp() { + // Trampoline so the whole Rx chain (subscribeOn/observeOn) runs synchronously in-thread and + // the subscribe { } lambda has fired before clearUserSettings() returns. + whenever(aapsSchedulers.io).thenReturn(Schedulers.trampoline()) + disposable = CompositeDisposable() + sut = CarelevoSettingsCoordinator(aapsLogger, aapsSchedulers, deleteUserSettingInfoUseCase) + } + + @Test + fun `clearUserSettings logs success when the delete succeeds`() { + stubDeleteResult(ResponseResult.Success(ResultSuccess)) + + sut.clearUserSettings(disposable) + + verify(deleteUserSettingInfoUseCase).execute() + verify(aapsLogger).debug(LTag.PUMPCOMM, "deleteUserSettingInfo.success") + } + + @Test + fun `clearUserSettings logs the response error when the delete returns Error`() { + val boom = RuntimeException("delete failed") + stubDeleteResult(ResponseResult.Error(boom)) + + sut.clearUserSettings(disposable) + + verify(aapsLogger).debug(LTag.PUMPCOMM, "deleteUserSettingInfo.responseError error=$boom") + } + + @Test + fun `clearUserSettings logs a generic failure when the delete returns Failure`() { + stubDeleteResult(ResponseResult.Failure("nope")) + + sut.clearUserSettings(disposable) + + verify(aapsLogger).debug(LTag.PUMPCOMM, "deleteUserSettingInfo.failure") + } + + @Test + fun `clearUserSettings subscribes through the use case`() { + stubDeleteResult(ResponseResult.Success(ResultSuccess)) + + sut.clearUserSettings(disposable) + + verify(deleteUserSettingInfoUseCase).execute() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoTempBasalCoordinatorTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoTempBasalCoordinatorTest.kt new file mode 100644 index 000000000000..62b59e38ac46 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/coordinator/CarelevoTempBasalCoordinatorTest.kt @@ -0,0 +1,391 @@ +package app.aaps.pump.carelevo.coordinator + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.pump.carelevo.ble.CarelevoBleSession +import app.aaps.pump.carelevo.ble.commands.SimpleResultResponse +import app.aaps.pump.carelevo.ble.commands.TempBasalCancelCommand +import app.aaps.pump.carelevo.ble.commands.TempBasalCommand +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.domain.usecase.basal.CarelevoCancelTempBasalInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.basal.CarelevoStartTempBasalInfusionUseCase +import app.aaps.pump.carelevo.domain.usecase.basal.model.StartTempBasalInfusionRequestModel +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.isA +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import javax.inject.Provider + +/** + * DIRECT unit tests of [CarelevoTempBasalCoordinator] — exercises the coordinator without going through + * the plugin, so the percent path, the cancel recompute-mode persist, the pump-authoritative sync on a + * failed local persist, and the exception→failed-result mapping are all hit at the source. Complements + * (does not duplicate) the plugin-level `CarelevoPumpPluginTempBasalTest`. + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoTempBasalCoordinatorTest { + + @Mock lateinit var aapsLogger: AAPSLogger + @Mock lateinit var dateUtil: DateUtil + @Mock lateinit var pumpSync: PumpSync + @Mock lateinit var carelevoPatch: CarelevoPatch + @Mock lateinit var bleSession: CarelevoBleSession + @Mock lateinit var startTempBasalInfusionUseCase: CarelevoStartTempBasalInfusionUseCase + @Mock lateinit var cancelTempBasalInfusionUseCase: CarelevoCancelTempBasalInfusionUseCase + + private val serial = "SN-CARELEVO" + private lateinit var sut: CarelevoTempBasalCoordinator + + @BeforeEach + fun setUp() { + whenever(dateUtil.now()).thenReturn(1_700_000_000_000L) + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn("AA:BB:CC:DD:EE:FF") + whenever(startTempBasalInfusionUseCase.persistTempBasalStarted(any())).thenReturn(true) + whenever(cancelTempBasalInfusionUseCase.persistTempBasalCancelled()).thenReturn(true) + // Default happy-path session replies; individual tests override for reject/exception branches. + whenever { bleSession.runSingle(any(), isA(), any()) }.thenReturn(SimpleResultResponse(0)) + whenever { bleSession.runSingle(any(), isA(), any()) }.thenReturn(SimpleResultResponse(0)) + + val pumpEnactResultProvider = Provider { FakePumpEnactResult() } + sut = CarelevoTempBasalCoordinator( + aapsLogger = aapsLogger, + dateUtil = dateUtil, + pumpSync = pumpSync, + pumpEnactResultProvider = pumpEnactResultProvider, + carelevoPatch = carelevoPatch, + bleSession = bleSession, + startTempBasalInfusionUseCase = startTempBasalInfusionUseCase, + cancelTempBasalInfusionUseCase = cancelTempBasalInfusionUseCase + ) + } + + // region setTempBasalAbsolute + + @Test + fun `setTempBasalAbsolute rejects a negative rate before touching the session`() { + assertThrows(IllegalArgumentException::class.java) { + sut.setTempBasalAbsolute(-0.1, 30, PumpSync.TemporaryBasalType.NORMAL, serial) {} + } + } + + @Test + fun `setTempBasalAbsolute rejects a non-positive duration before touching the session`() { + assertThrows(IllegalArgumentException::class.java) { + sut.setTempBasalAbsolute(1.2, 0, PumpSync.TemporaryBasalType.NORMAL, serial) {} + } + } + + @Test + fun `setTempBasalAbsolute returns not enacted and never dials when bluetooth is off`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(false) + var lastDataUpdated = false + + val result = sut.setTempBasalAbsolute(1.2, 30, PumpSync.TemporaryBasalType.NORMAL, serial) { lastDataUpdated = true } + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(lastDataUpdated).isFalse() + verifyBlocking(bleSession, never()) { runSingle(any(), isA(), any()) } + verifyBlocking(pumpSync, never()) { syncTemporaryBasalWithPumpId(any(), any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `setTempBasalAbsolute returns no patch address comment when the address is missing`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + + val result = sut.setTempBasalAbsolute(1.2, 30, PumpSync.TemporaryBasalType.NORMAL, serial) {} + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(result.comment).isEqualTo("no patch address") + } + + @Test + fun `setTempBasalAbsolute success populates the result, persists and syncs`() { + var lastDataUpdated = false + + val result = sut.setTempBasalAbsolute(1.2, 90, PumpSync.TemporaryBasalType.NORMAL, serial) { lastDataUpdated = true } + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + assertThat(result.duration).isEqualTo(90) + assertThat(result.absolute).isWithin(0.001).of(1.2) + assertThat(result.isPercent).isFalse() + assertThat(result.isTempCancel).isFalse() + assertThat(lastDataUpdated).isTrue() + verify(startTempBasalInfusionUseCase).persistTempBasalStarted(StartTempBasalInfusionRequestModel(isUnit = true, speed = 1.2, minutes = 90)) + verifyBlocking(pumpSync) { syncTemporaryBasalWithPumpId(any(), any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `setTempBasalAbsolute records in pumpSync even when the local persist fails`() { + // Pump is authoritative: on ACK the TBR IS running, so a failed persist must not keep it out of pumpSync. + whenever(startTempBasalInfusionUseCase.persistTempBasalStarted(any())).thenReturn(false) + + val result = sut.setTempBasalAbsolute(1.2, 30, PumpSync.TemporaryBasalType.NORMAL, serial) {} + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + verifyBlocking(pumpSync) { syncTemporaryBasalWithPumpId(any(), any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `setTempBasalAbsolute maps a pump reject to an internal error without persisting or syncing`() { + whenever { bleSession.runSingle(any(), isA(), any()) }.thenReturn(SimpleResultResponse(1)) + var lastDataUpdated = false + + val result = sut.setTempBasalAbsolute(1.2, 30, PumpSync.TemporaryBasalType.NORMAL, serial) { lastDataUpdated = true } + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(result.comment).isEqualTo("Internal error") + assertThat(lastDataUpdated).isFalse() + verify(startTempBasalInfusionUseCase, never()).persistTempBasalStarted(any()) + verifyBlocking(pumpSync, never()) { syncTemporaryBasalWithPumpId(any(), any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `setTempBasalAbsolute maps a session exception to a failed result carrying the message`() { + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenAnswer { throw IllegalStateException("timeout") } + + val result = sut.setTempBasalAbsolute(1.2, 30, PumpSync.TemporaryBasalType.NORMAL, serial) {} + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(result.comment).isEqualTo("timeout") + verifyBlocking(pumpSync, never()) { syncTemporaryBasalWithPumpId(any(), any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `setTempBasalAbsolute falls back to error comment when the exception has no message`() { + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenAnswer { throw RuntimeException() } + + val result = sut.setTempBasalAbsolute(1.2, 30, PumpSync.TemporaryBasalType.NORMAL, serial) {} + + assertThat(result.success).isFalse() + assertThat(result.comment).isEqualTo("error") + } + + // endregion + + // region setTempBasalPercent + + @Test + fun `setTempBasalPercent rejects a negative percent before touching the session`() { + assertThrows(IllegalArgumentException::class.java) { + sut.setTempBasalPercent(-1, 30, PumpSync.TemporaryBasalType.NORMAL, serial) {} + } + } + + @Test + fun `setTempBasalPercent rejects a non-positive duration before touching the session`() { + assertThrows(IllegalArgumentException::class.java) { + sut.setTempBasalPercent(150, 0, PumpSync.TemporaryBasalType.NORMAL, serial) {} + } + } + + @Test + fun `setTempBasalPercent returns not enacted and never dials when bluetooth is off`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(false) + var lastDataUpdated = false + + val result = sut.setTempBasalPercent(150, 30, PumpSync.TemporaryBasalType.NORMAL, serial) { lastDataUpdated = true } + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(lastDataUpdated).isFalse() + verifyBlocking(bleSession, never()) { runSingle(any(), isA(), any()) } + } + + @Test + fun `setTempBasalPercent returns no patch address comment when the address is missing`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + + val result = sut.setTempBasalPercent(150, 30, PumpSync.TemporaryBasalType.NORMAL, serial) {} + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(result.comment).isEqualTo("no patch address") + } + + @Test + fun `setTempBasalPercent success populates the result, persists by percent and syncs`() { + var lastDataUpdated = false + + val result = sut.setTempBasalPercent(150, 30, PumpSync.TemporaryBasalType.NORMAL, serial) { lastDataUpdated = true } + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + assertThat(result.duration).isEqualTo(30) + assertThat(result.percent).isEqualTo(150) + assertThat(result.isPercent).isTrue() + assertThat(result.isTempCancel).isFalse() + assertThat(lastDataUpdated).isTrue() + verify(startTempBasalInfusionUseCase).persistTempBasalStarted(StartTempBasalInfusionRequestModel(isUnit = false, percent = 150, minutes = 30)) + verifyBlocking(pumpSync) { syncTemporaryBasalWithPumpId(any(), any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `setTempBasalPercent records in pumpSync even when the local persist fails`() { + whenever(startTempBasalInfusionUseCase.persistTempBasalStarted(any())).thenReturn(false) + + val result = sut.setTempBasalPercent(150, 30, PumpSync.TemporaryBasalType.NORMAL, serial) {} + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + verifyBlocking(pumpSync) { syncTemporaryBasalWithPumpId(any(), any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `setTempBasalPercent maps a pump reject to an internal error without persisting or syncing`() { + whenever { bleSession.runSingle(any(), isA(), any()) }.thenReturn(SimpleResultResponse(1)) + var lastDataUpdated = false + + val result = sut.setTempBasalPercent(150, 30, PumpSync.TemporaryBasalType.NORMAL, serial) { lastDataUpdated = true } + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(result.comment).isEqualTo("Internal error") + assertThat(lastDataUpdated).isFalse() + verify(startTempBasalInfusionUseCase, never()).persistTempBasalStarted(any()) + verifyBlocking(pumpSync, never()) { syncTemporaryBasalWithPumpId(any(), any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `setTempBasalPercent falls back to error comment when the exception has no message`() { + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenAnswer { throw RuntimeException() } + + val result = sut.setTempBasalPercent(150, 30, PumpSync.TemporaryBasalType.NORMAL, serial) {} + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(result.comment).isEqualTo("error") + } + + // endregion + + // region cancelTempBasal + + @Test + fun `cancelTempBasal returns not enacted and never dials when bluetooth is off`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(false) + var lastDataUpdated = false + + val result = sut.cancelTempBasal(serial) { lastDataUpdated = true } + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(lastDataUpdated).isFalse() + verifyBlocking(bleSession, never()) { runSingle(any(), isA(), any()) } + } + + @Test + fun `cancelTempBasal returns no patch address comment when the address is missing`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + + val result = sut.cancelTempBasal(serial) {} + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(result.comment).isEqualTo("no patch address") + } + + @Test + fun `cancelTempBasal success recompute-mode persists and syncs the stop`() { + var lastDataUpdated = false + + val result = sut.cancelTempBasal(serial) { lastDataUpdated = true } + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + assertThat(result.isTempCancel).isTrue() + assertThat(lastDataUpdated).isTrue() + verify(cancelTempBasalInfusionUseCase).persistTempBasalCancelled() + verifyBlocking(pumpSync) { syncStopTemporaryBasalWithPumpId(any(), any(), any(), any(), any()) } + } + + @Test + fun `cancelTempBasal records the stop even when the local persist fails`() { + whenever(cancelTempBasalInfusionUseCase.persistTempBasalCancelled()).thenReturn(false) + + val result = sut.cancelTempBasal(serial) {} + + assertThat(result.success).isTrue() + assertThat(result.enacted).isTrue() + assertThat(result.isTempCancel).isTrue() + verifyBlocking(pumpSync) { syncStopTemporaryBasalWithPumpId(any(), any(), any(), any(), any()) } + } + + @Test + fun `cancelTempBasal maps a pump reject to a failed result without persisting or syncing`() { + whenever { bleSession.runSingle(any(), isA(), any()) }.thenReturn(SimpleResultResponse(1)) + var lastDataUpdated = false + + val result = sut.cancelTempBasal(serial) { lastDataUpdated = true } + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + assertThat(lastDataUpdated).isFalse() + verify(cancelTempBasalInfusionUseCase, never()).persistTempBasalCancelled() + verifyBlocking(pumpSync, never()) { syncStopTemporaryBasalWithPumpId(any(), any(), any(), any(), any()) } + } + + @Test + fun `cancelTempBasal maps a session exception to a failed result`() { + whenever { bleSession.runSingle(any(), isA(), any()) } + .thenAnswer { throw IllegalStateException("timeout") } + + val result = sut.cancelTempBasal(serial) {} + + assertThat(result.success).isFalse() + assertThat(result.enacted).isFalse() + verifyBlocking(pumpSync, never()) { syncStopTemporaryBasalWithPumpId(any(), any(), any(), any(), any()) } + } + + // endregion + + /** Concrete [PumpEnactResult] so the builder chain and field mutations are observable in assertions. */ + private class FakePumpEnactResult : PumpEnactResult { + + override var success: Boolean = false + override var enacted: Boolean = false + override var comment: String = "" + override var duration: Int = -1 + override var absolute: Double = -1.0 + override var percent: Int = -1 + override var isPercent: Boolean = false + override var isTempCancel: Boolean = false + override var bolusDelivered: Double = 0.0 + override var queued: Boolean = false + + override fun success(success: Boolean): PumpEnactResult = apply { this.success = success } + override fun enacted(enacted: Boolean): PumpEnactResult = apply { this.enacted = enacted } + override fun comment(comment: String): PumpEnactResult = apply { this.comment = comment } + override fun comment(comment: Int): PumpEnactResult = apply { this.comment = comment.toString() } + override fun duration(duration: Int): PumpEnactResult = apply { this.duration = duration } + override fun absolute(absolute: Double): PumpEnactResult = apply { this.absolute = absolute } + override fun percent(percent: Int): PumpEnactResult = apply { this.percent = percent } + override fun isPercent(isPercent: Boolean): PumpEnactResult = apply { this.isPercent = isPercent } + override fun isTempCancel(isTempCancel: Boolean): PumpEnactResult = apply { this.isTempCancel = isTempCancel } + override fun bolusDelivered(bolusDelivered: Double): PumpEnactResult = apply { this.bolusDelivered = bolusDelivered } + override fun queued(queued: Boolean): PumpEnactResult = apply { this.queued = queued } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoAlarmInfoDaoImplTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoAlarmInfoDaoImplTest.kt new file mode 100644 index 000000000000..277c40eb9df1 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoAlarmInfoDaoImplTest.kt @@ -0,0 +1,418 @@ +package app.aaps.pump.carelevo.data.dao + +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.pump.carelevo.config.PrefEnvConfig +import app.aaps.pump.carelevo.data.common.CarelevoGsonHelper +import app.aaps.pump.carelevo.data.model.entities.CarelevoAlarmInfoEntity +import app.aaps.pump.carelevo.domain.type.AlarmCause +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +/** + * Unit coverage for [CarelevoAlarmInfoDaoImpl]. + * + * The DAO persists the alarm list as a Gson-serialized JSON string in [SP] under a single key. The + * mocked [SP] is driven as an in-memory fake — a backing map answers `getString`/`putString`/`remove` + * — so every method exercises the *real* [CarelevoGsonHelper] serialization round trip. + * + * The load path is deliberately UNFILTERED: everything in the store is an active (unacknowledged) + * alarm by construction because acknowledging an alarm removes it (see `removeAlarm`). The regression + * these tests guard against is the old vendor code filtering the cold-load stream to `it.acknowledged` + * — which is never true — silently dropping every persisted active alarm on process restart. + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoAlarmInfoDaoImplTest { + + @Mock lateinit var prefManager: SP + + private val key = PrefEnvConfig.CARELEVO_ALARM_INFO_LIST + private val store = mutableMapOf() + + private lateinit var sut: CarelevoAlarmInfoDaoImpl + + @BeforeEach + fun setUp() { + // Fake shared-preferences: a backing map behind the three String-keyed methods the DAO uses. + // any() picks the String overload over the @StringRes Int overload of each. + whenever(prefManager.getString(any(), any())).thenAnswer { + val k = it.getArgument(0) + val default = it.getArgument(1) + store[k] ?: default + } + doAnswer { + store[it.getArgument(0)] = it.getArgument(1) + null + }.whenever(prefManager).putString(any(), any()) + doAnswer { + store.remove(it.getArgument(0)) + null + }.whenever(prefManager).remove(any()) + + sut = CarelevoAlarmInfoDaoImpl(prefManager) + } + + // --------------------------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------------------------- + + private fun entity( + alarmId: String = "alarm-1", + cause: AlarmCause = AlarmCause.ALARM_ALERT_OUT_OF_INSULIN, + alarmType: Int = cause.alarmType.code, + value: Int? = null, + acknowledged: Boolean = false, + occurrenceCount: Int = 1, + createdAt: String = "2026-07-16T10:00:00", + updatedAt: String = "2026-07-16T10:00:00" + ): CarelevoAlarmInfoEntity = CarelevoAlarmInfoEntity( + alarmId = alarmId, + alarmType = alarmType, + cause = cause, + value = value, + createdAt = createdAt, + updatedAt = updatedAt, + acknowledged = acknowledged, + occurrenceCount = occurrenceCount + ) + + /** Seed the persisted store as a prior process would have — through the real Gson helper. */ + private fun seedStore(vararg alarms: CarelevoAlarmInfoEntity) { + store[key] = CarelevoGsonHelper.sharedGson().toJson(alarms.toList()) + } + + // --------------------------------------------------------------------------------------------- + // getAlarms — cold load + // --------------------------------------------------------------------------------------------- + + @Test + fun `getAlarms cold-loads an empty list when nothing is persisted`() { + val result = sut.getAlarms().blockingFirst() + assertThat(result.isPresent).isTrue() + assertThat(result.get()).isEmpty() + } + + @Test + fun `getAlarms cold-loads every persisted alarm UNFILTERED including acknowledged ones`() { + val active = entity(alarmId = "active", cause = AlarmCause.ALARM_ALERT_OUT_OF_INSULIN, acknowledged = false) + val acked = entity(alarmId = "acked", cause = AlarmCause.ALARM_WARNING_PUMP_CLOGGED, acknowledged = true) + seedStore(active, acked) + + val result = sut.getAlarms().blockingFirst() + + assertThat(result.isPresent).isTrue() + // The regression guard: BOTH alarms survive the cold load, not just the (never-true) acknowledged one. + assertThat(result.get()).containsExactly(active, acked) + } + + @Test + fun `getAlarms emits Optional-empty and does not crash when the persisted JSON is unparseable`() { + store[key] = "{}" // an object where an array is expected → Gson throws, runCatching swallows it + + val result = sut.getAlarms().blockingFirst() + + assertThat(result.isPresent).isFalse() + } + + @Test + fun `getAlarms warms the cache and does not re-read preferences on the second call`() { + seedStore(entity()) + assertThat(sut.getAlarms().blockingFirst().get()).hasSize(1) + + store[key] = "{}" // corrupt the store; a second read would blow up if it re-read + val second = sut.getAlarms().blockingFirst() + + assertThat(second.get()).hasSize(1) + verify(prefManager, times(1)).getString(any(), any()) + } + + @Test + fun `getAlarms returns the live subject and emits subsequent upserts to existing subscribers`() { + val observer = sut.getAlarms().test() + + sut.upsertAlarm(entity()).blockingAwait() + + observer.assertValueCount(2) // initial cold-load empty list + post-upsert list + assertThat(observer.values().last().get()).hasSize(1) + } + + @Test + fun `getAlarms does not reload after clearAlarms even when the store is re-seeded`() { + seedStore(entity()) + sut.getAlarms().blockingFirst() + sut.clearAlarms().blockingAwait() // value becomes Optional-empty (non-null) → guard stays false + + seedStore(entity("a1"), entity("a2")) + val after = sut.getAlarms().blockingFirst() + + assertThat(after.isPresent).isFalse() + } + + // --------------------------------------------------------------------------------------------- + // getAlarmsOnce + // --------------------------------------------------------------------------------------------- + + @Test + fun `getAlarmsOnce cold-loads an empty list when nothing is persisted`() { + val result = sut.getAlarmsOnce().blockingGet() + assertThat(result.isPresent).isTrue() + assertThat(result.get()).isEmpty() + } + + @Test + fun `getAlarmsOnce returns all stored alarms unfiltered`() { + val a = entity(alarmId = "a", cause = AlarmCause.ALARM_WARNING_LOW_INSULIN, acknowledged = false) + val b = entity(alarmId = "b", cause = AlarmCause.ALARM_NOTICE_LGS_START, acknowledged = true) + seedStore(a, b) + + val result = sut.getAlarmsOnce().blockingGet() + + assertThat(result.get()).containsExactly(a, b) + } + + @Test + fun `getAlarmsOnce uses the warm cache after a prior setAlarms`() { + val list = listOf(entity()) + sut.setAlarms(list).blockingAwait() + + store[key] = "{}" // would fail to parse if the cache were bypassed + val result = sut.getAlarmsOnce().blockingGet() + + assertThat(result.get()).isEqualTo(list) + } + + @Test + fun `getAlarmsOnce surfaces the parse error when the persisted JSON is unparseable`() { + store[key] = "{}" + + sut.getAlarmsOnce().test().assertError(RuntimeException::class.java) + } + + @Test + fun `getAlarmsOnce reloads from the store after clearAlarms clears the cache to empty`() { + seedStore(entity()) + sut.getAlarms().blockingFirst() + sut.clearAlarms().blockingAwait() // key removed, cache Optional-empty → ensureLoaded re-reads store + + val result = sut.getAlarmsOnce().blockingGet() + + assertThat(result.get()).isEmpty() + } + + // --------------------------------------------------------------------------------------------- + // setAlarms + // --------------------------------------------------------------------------------------------- + + @Test + fun `setAlarms persists the list as JSON and pushes it onto the stream`() { + val list = listOf(entity(alarmId = "x"), entity(alarmId = "y", cause = AlarmCause.ALARM_WARNING_LOW_BATTERY)) + + sut.setAlarms(list).blockingAwait() + + verify(prefManager).putString(eq(key), any()) + assertThat(store).containsKey(key) + assertThat(sut.getAlarms().blockingFirst().get()).containsExactlyElementsIn(list) + } + + @Test + fun `setAlarms overwrites the previously persisted content`() { + sut.setAlarms(listOf(entity(alarmId = "first"))).blockingAwait() + sut.setAlarms(listOf(entity(alarmId = "second"))).blockingAwait() + + val reloaded = CarelevoAlarmInfoDaoImpl(prefManager).getAlarmsOnce().blockingGet() + assertThat(reloaded.get().map { it.alarmId }).containsExactly("second") + } + + // --------------------------------------------------------------------------------------------- + // clearAlarms + // --------------------------------------------------------------------------------------------- + + @Test + fun `clearAlarms removes the preference key and emits Optional-empty`() { + seedStore(entity()) + sut.getAlarms().blockingFirst() // warm the cache first + + sut.clearAlarms().blockingAwait() + + verify(prefManager).remove(eq(key)) + assertThat(store).doesNotContainKey(key) + assertThat(sut.getAlarms().blockingFirst().isPresent).isFalse() + } + + // --------------------------------------------------------------------------------------------- + // upsertAlarm + // --------------------------------------------------------------------------------------------- + + @Test + fun `upsertAlarm appends a brand new alarm and forces its occurrenceCount to one`() { + // occurrenceCount passed in (7) must be normalized to 1 for a first-seen alarm. + sut.upsertAlarm(entity(alarmId = "new", occurrenceCount = 7)).blockingAwait() + + val stored = sut.getAlarms().blockingFirst().get() + assertThat(stored).hasSize(1) + assertThat(stored.single().occurrenceCount).isEqualTo(1) + } + + @Test + fun `upsertAlarm increments occurrenceCount and refreshes updatedAt on an existing match`() { + val existing = entity( + alarmId = "existing", + cause = AlarmCause.ALARM_ALERT_OUT_OF_INSULIN, + acknowledged = false, + occurrenceCount = 1, + createdAt = "2026-07-16T09:00:00", + updatedAt = "2026-07-16T09:00:00" + ) + seedStore(existing) + sut.getAlarms().blockingFirst() + + // Same alarmType + cause + unacknowledged, but a different id/updatedAt. + sut.upsertAlarm( + entity(alarmId = "incoming", cause = AlarmCause.ALARM_ALERT_OUT_OF_INSULIN, updatedAt = "2026-07-16T11:00:00") + ).blockingAwait() + + val stored = sut.getAlarms().blockingFirst().get() + assertThat(stored).hasSize(1) + val merged = stored.single() + assertThat(merged.occurrenceCount).isEqualTo(2) + assertThat(merged.updatedAt).isEqualTo("2026-07-16T11:00:00") + // Identity of the existing row is preserved (copy, not replace). + assertThat(merged.alarmId).isEqualTo("existing") + assertThat(merged.createdAt).isEqualTo("2026-07-16T09:00:00") + } + + @Test + fun `upsertAlarm appends instead of merging when the only match is acknowledged`() { + seedStore(entity(alarmId = "acked", cause = AlarmCause.ALARM_ALERT_OUT_OF_INSULIN, acknowledged = true)) + sut.getAlarms().blockingFirst() + + sut.upsertAlarm(entity(alarmId = "fresh", cause = AlarmCause.ALARM_ALERT_OUT_OF_INSULIN)).blockingAwait() + + val stored = sut.getAlarms().blockingFirst().get() + assertThat(stored).hasSize(2) + } + + @Test + fun `upsertAlarm appends when the cause differs from every stored alarm`() { + seedStore(entity(alarmId = "one", cause = AlarmCause.ALARM_ALERT_OUT_OF_INSULIN)) + sut.getAlarms().blockingFirst() + + sut.upsertAlarm(entity(alarmId = "two", cause = AlarmCause.ALARM_ALERT_LOW_BATTERY)).blockingAwait() + + val stored = sut.getAlarms().blockingFirst().get() + assertThat(stored).hasSize(2) + assertThat(stored.map { it.cause }).containsExactly( + AlarmCause.ALARM_ALERT_OUT_OF_INSULIN, AlarmCause.ALARM_ALERT_LOW_BATTERY + ) + } + + @Test + fun `upsertAlarm appends when the alarmType differs despite an identical cause`() { + // Same cause, but a deliberately mismatched alarmType exercises the alarmType leg of the match. + seedStore(entity(alarmId = "weird", cause = AlarmCause.ALARM_ALERT_OUT_OF_INSULIN, alarmType = 999)) + sut.getAlarms().blockingFirst() + + sut.upsertAlarm(entity(alarmId = "normal", cause = AlarmCause.ALARM_ALERT_OUT_OF_INSULIN)).blockingAwait() + + val stored = sut.getAlarms().blockingFirst().get() + assertThat(stored).hasSize(2) + } + + @Test + fun `upsertAlarm keeps incrementing occurrenceCount across repeated calls`() { + val alarm = entity(alarmId = "repeat", cause = AlarmCause.ALARM_WARNING_LOW_INSULIN) + sut.upsertAlarm(alarm).blockingAwait() + sut.upsertAlarm(alarm).blockingAwait() + sut.upsertAlarm(alarm).blockingAwait() + + val stored = sut.getAlarms().blockingFirst().get() + assertThat(stored).hasSize(1) + assertThat(stored.single().occurrenceCount).isEqualTo(3) + } + + // --------------------------------------------------------------------------------------------- + // removeAlarm + // --------------------------------------------------------------------------------------------- + + @Test + fun `removeAlarm drops the matching id and persists the remainder`() { + seedStore(entity(alarmId = "keep"), entity(alarmId = "drop", cause = AlarmCause.ALARM_WARNING_LOW_BATTERY)) + sut.getAlarms().blockingFirst() + + sut.removeAlarm("drop").blockingAwait() + + val stored = sut.getAlarms().blockingFirst().get() + assertThat(stored.map { it.alarmId }).containsExactly("keep") + // Persisted too: a fresh cold read agrees. + val reloaded = CarelevoAlarmInfoDaoImpl(prefManager).getAlarmsOnce().blockingGet() + assertThat(reloaded.get().map { it.alarmId }).containsExactly("keep") + } + + @Test + fun `removeAlarm with an unknown id leaves the list intact but still persists and emits`() { + seedStore(entity(alarmId = "a"), entity(alarmId = "b", cause = AlarmCause.ALARM_WARNING_LOW_BATTERY)) + sut.getAlarms().blockingFirst() + + sut.removeAlarm("does-not-exist").blockingAwait() + + val stored = sut.getAlarms().blockingFirst().get() + assertThat(stored.map { it.alarmId }).containsExactly("a", "b") + verify(prefManager, times(1)).putString(eq(key), any()) + } + + @Test + fun `removeAlarm emits the updated list to existing stream subscribers`() { + seedStore(entity(alarmId = "a"), entity(alarmId = "b", cause = AlarmCause.ALARM_WARNING_LOW_BATTERY)) + val observer = sut.getAlarms().test() + + sut.removeAlarm("a").blockingAwait() + + assertThat(observer.values().last().get().map { it.alarmId }).containsExactly("b") + } + + // --------------------------------------------------------------------------------------------- + // End-to-end serialization + // --------------------------------------------------------------------------------------------- + + @Test + fun `a fresh DAO cold-reads exactly what a prior DAO persisted through Gson`() { + val list = listOf( + entity(alarmId = "with-value", cause = AlarmCause.ALARM_NOTICE_LGS_FINISHED_HIGH_BG, value = 5, acknowledged = true, occurrenceCount = 3), + entity(alarmId = "plain", cause = AlarmCause.ALARM_WARNING_PATCH_ERROR) + ) + sut.setAlarms(list).blockingAwait() + + val fresh = CarelevoAlarmInfoDaoImpl(prefManager) + val read = fresh.getAlarmsOnce().blockingGet() + + assertThat(read.isPresent).isTrue() + // data-class equality covers alarmType, cause enum, value, occurrenceCount, acknowledged, timestamps. + assertThat(read.get()).containsExactlyElementsIn(list) + } + + @Test + fun `verifies the fake never touches the StringRes overloads`() { + // Sanity guard on the fake wiring: the DAO only uses the String-keyed SP overloads. + seedStore(entity()) + sut.getAlarms().blockingFirst() + sut.setAlarms(listOf(entity())).blockingAwait() + sut.clearAlarms().blockingAwait() + + verify(prefManager, never()).getString(any(), any()) + verify(prefManager, never()).putString(any(), any()) + verify(prefManager, never()).remove(any()) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoInfusionInfoDaoImplTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoInfusionInfoDaoImplTest.kt new file mode 100644 index 000000000000..962b33df1af0 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoInfusionInfoDaoImplTest.kt @@ -0,0 +1,382 @@ +package app.aaps.pump.carelevo.data.dao + +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.pump.carelevo.config.PrefEnvConfig +import app.aaps.pump.carelevo.data.common.CarelevoGsonHelper +import app.aaps.pump.carelevo.data.model.entities.CarelevoBasalInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoBasalSegmentInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoExtendBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoImmeBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoTempBasalInfusionInfoEntity +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.Optional + +/** + * Unit tests for [CarelevoInfusionInfoDaoImpl] — exercises the SharedPreferences-backed round-trip + * (save / load / update-field / delete) using an in-memory [MutableMap] behind the mocked [SP], plus + * the aggregate [CarelevoInfusionInfoEntity] BehaviorSubject folding, empty/absent-record collapse, + * malformed-JSON tolerance and persistence-failure branches. + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoInfusionInfoDaoImplTest { + + @Mock lateinit var prefManager: SP + + private lateinit var store: MutableMap + private lateinit var sut: CarelevoInfusionInfoDaoImpl + + private val gson get() = CarelevoGsonHelper.sharedGson() + + // ---- entity builders ------------------------------------------------------------------- + + private fun basal(id: String = "b1") = CarelevoBasalInfusionInfoEntity( + infusionId = id, + address = "AA:BB:CC:DD:EE:FF", + mode = 1, + createdAt = "2026-07-16T10:00:00", + updatedAt = "2026-07-16T10:00:00", + segments = listOf( + CarelevoBasalSegmentInfusionInfoEntity( + createdAt = "2026-07-16T10:00:00", + updatedAt = "2026-07-16T10:00:00", + startTime = 0, + endTime = 60, + speed = 1.0 + ) + ), + isStop = false + ) + + private fun tempBasal(id: String = "tb1") = CarelevoTempBasalInfusionInfoEntity( + infusionId = id, + address = "AA:BB:CC:DD:EE:FF", + mode = 2, + createdAt = "2026-07-16T10:00:00", + updatedAt = "2026-07-16T10:00:00", + percent = 150, + speed = 1.5, + infusionDurationMin = 30 + ) + + private fun immeBolus(id: String = "ib1") = CarelevoImmeBolusInfusionInfoEntity( + infusionId = id, + address = "AA:BB:CC:DD:EE:FF", + mode = 3, + createdAt = "2026-07-16T10:00:00", + updatedAt = "2026-07-16T10:00:00", + volume = 2.0, + infusionDurationSeconds = 120 + ) + + private fun extendBolus(id: String = "eb1") = CarelevoExtendBolusInfusionInfoEntity( + infusionId = id, + address = "AA:BB:CC:DD:EE:FF", + mode = 4, + createdAt = "2026-07-16T10:00:00", + updatedAt = "2026-07-16T10:00:00", + volume = 3.0, + speed = 0.5, + infusionDurationMin = 45 + ) + + private fun seed(key: String, value: Any) { + store[key] = gson.toJson(value) + } + + @BeforeEach + fun setUp() { + store = mutableMapOf() + // getString(key, default) — non-void, backed by the in-memory store. + whenever(prefManager.getString(any(), any())).thenAnswer { inv -> + val key = inv.getArgument(0) + val def = inv.getArgument(1) + store[key] ?: def + } + // putString(key, value) — void; write through to the store. + doAnswer { inv -> + store[inv.getArgument(0)] = inv.getArgument(1) + null + }.whenever(prefManager).putString(any(), any()) + // remove(key) — void; delete from the store. + doAnswer { inv -> + store.remove(inv.getArgument(0)) + null + }.whenever(prefManager).remove(any()) + + sut = CarelevoInfusionInfoDaoImpl(prefManager) + } + + // ---- getInfusionInfo / ensureLoaded ---------------------------------------------------- + + @Test + fun `getInfusionInfo on empty store emits an absent optional`() { + val result = sut.getInfusionInfo().blockingFirst() + assertThat(result.isPresent).isFalse() + } + + @Test + fun `getInfusionInfo seeds the aggregate from a stored basal record`() { + seed(PrefEnvConfig.BASAL_INFUSION_INFO, basal()) + + val result = sut.getInfusionInfo().blockingFirst() + + assertThat(result.isPresent).isTrue() + assertThat(result.get().basalInfusionInfo).isEqualTo(basal()) + assertThat(result.get().tempBasalInfusionInfo).isNull() + assertThat(result.get().immeBolusInfusionInfo).isNull() + assertThat(result.get().extendBolusInfusionInfo).isNull() + } + + @Test + fun `getInfusionInfoBySync on empty store returns null`() { + assertThat(sut.getInfusionInfoBySync()).isNull() + } + + @Test + fun `getInfusionInfoBySync seeds all four records from prefs`() { + seed(PrefEnvConfig.BASAL_INFUSION_INFO, basal()) + seed(PrefEnvConfig.TEMP_BASAL_INFUSION_INFO, tempBasal()) + seed(PrefEnvConfig.IMME_BOLUS_INFUSION_INFO, immeBolus()) + seed(PrefEnvConfig.EXTEND_BOLUS_INFUSION_INFO, extendBolus()) + + val agg = sut.getInfusionInfoBySync() + + assertThat(agg).isNotNull() + assertThat(agg!!.basalInfusionInfo).isEqualTo(basal()) + assertThat(agg.tempBasalInfusionInfo).isEqualTo(tempBasal()) + assertThat(agg.immeBolusInfusionInfo).isEqualTo(immeBolus()) + assertThat(agg.extendBolusInfusionInfo).isEqualTo(extendBolus()) + } + + @Test + fun `ensureLoaded reads prefs only once then serves the cached subject value`() { + // First read seeds from an empty store (absent). + assertThat(sut.getInfusionInfoBySync()).isNull() + // Even though data appears in prefs afterwards, the cached subject is not reloaded. + seed(PrefEnvConfig.BASAL_INFUSION_INFO, basal()) + assertThat(sut.getInfusionInfoBySync()).isNull() + + // Exactly one load pass = four getString calls (one per per-mode key). + verify(prefManager, times(4)).getString(any(), any()) + } + + @Test + fun `loadEntity tolerates malformed json by treating that record as absent`() { + store[PrefEnvConfig.BASAL_INFUSION_INFO] = "[1,2,3]" // not a JSON object → parse fails + seed(PrefEnvConfig.TEMP_BASAL_INFUSION_INFO, tempBasal()) + + val agg = sut.getInfusionInfoBySync() + + assertThat(agg).isNotNull() + assertThat(agg!!.basalInfusionInfo).isNull() + assertThat(agg.tempBasalInfusionInfo).isEqualTo(tempBasal()) + } + + // ---- updateXxxInfusionInfo (success + fold) -------------------------------------------- + + @Test + fun `updateBasalInfusionInfo persists json and folds into the aggregate`() { + val b = basal() + + assertThat(sut.updateBasalInfusionInfo(b)).isTrue() + + assertThat(store[PrefEnvConfig.BASAL_INFUSION_INFO]).isEqualTo(gson.toJson(b)) + assertThat(sut.getInfusionInfoBySync()!!.basalInfusionInfo).isEqualTo(b) + verify(prefManager).putString(eq(PrefEnvConfig.BASAL_INFUSION_INFO), any()) + } + + @Test + fun `updateTempBasalInfusionInfo persists json and folds into the aggregate`() { + val tb = tempBasal() + + assertThat(sut.updateTempBasalInfusionInfo(tb)).isTrue() + + assertThat(store[PrefEnvConfig.TEMP_BASAL_INFUSION_INFO]).isEqualTo(gson.toJson(tb)) + assertThat(sut.getInfusionInfoBySync()!!.tempBasalInfusionInfo).isEqualTo(tb) + verify(prefManager).putString(eq(PrefEnvConfig.TEMP_BASAL_INFUSION_INFO), any()) + } + + @Test + fun `updateImmeBolusInfusionInfo persists json and folds into the aggregate`() { + val ib = immeBolus() + + assertThat(sut.updateImmeBolusInfusionInfo(ib)).isTrue() + + assertThat(store[PrefEnvConfig.IMME_BOLUS_INFUSION_INFO]).isEqualTo(gson.toJson(ib)) + assertThat(sut.getInfusionInfoBySync()!!.immeBolusInfusionInfo).isEqualTo(ib) + verify(prefManager).putString(eq(PrefEnvConfig.IMME_BOLUS_INFUSION_INFO), any()) + } + + @Test + fun `updateExtendBolusInfusionInfo persists json and folds into the aggregate`() { + val eb = extendBolus() + + assertThat(sut.updateExtendBolusInfusionInfo(eb)).isTrue() + + assertThat(store[PrefEnvConfig.EXTEND_BOLUS_INFUSION_INFO]).isEqualTo(gson.toJson(eb)) + assertThat(sut.getInfusionInfoBySync()!!.extendBolusInfusionInfo).isEqualTo(eb) + verify(prefManager).putString(eq(PrefEnvConfig.EXTEND_BOLUS_INFUSION_INFO), any()) + } + + @Test + fun `sequential updates accumulate onto the existing aggregate`() { + // First update takes the value==null branch; the rest fold onto the present aggregate. + assertThat(sut.updateBasalInfusionInfo(basal())).isTrue() + assertThat(sut.updateTempBasalInfusionInfo(tempBasal())).isTrue() + assertThat(sut.updateImmeBolusInfusionInfo(immeBolus())).isTrue() + assertThat(sut.updateExtendBolusInfusionInfo(extendBolus())).isTrue() + + val agg = sut.getInfusionInfoBySync()!! + assertThat(agg.basalInfusionInfo).isEqualTo(basal()) + assertThat(agg.tempBasalInfusionInfo).isEqualTo(tempBasal()) + assertThat(agg.immeBolusInfusionInfo).isEqualTo(immeBolus()) + assertThat(agg.extendBolusInfusionInfo).isEqualTo(extendBolus()) + } + + @Test + fun `updateBasalInfusionInfo returns false and leaves the aggregate untouched when persistence throws`() { + doThrow(RuntimeException("write failed")).whenever(prefManager).putString(any(), any()) + + assertThat(sut.updateBasalInfusionInfo(basal())).isFalse() + // Nothing was persisted, so a fresh load still reports absent. + assertThat(sut.getInfusionInfoBySync()).isNull() + } + + @Test + fun `updating emits the folded value on the observable`() { + val observer = sut.getInfusionInfo().test() + + assertThat(sut.updateBasalInfusionInfo(basal())).isTrue() + + val last: Optional = observer.values().last() + assertThat(last.orElse(null)?.basalInfusionInfo).isEqualTo(basal()) + observer.dispose() + } + + // ---- deleteXxxInfusionInfo ------------------------------------------------------------- + + @Test + fun `deleteBasalInfusionInfo removes the key and collapses the aggregate to absent`() { + assertThat(sut.updateBasalInfusionInfo(basal())).isTrue() + + assertThat(sut.deleteBasalInfusionInfo()).isTrue() + + assertThat(store.containsKey(PrefEnvConfig.BASAL_INFUSION_INFO)).isFalse() + assertThat(sut.getInfusionInfoBySync()).isNull() + verify(prefManager).remove(eq(PrefEnvConfig.BASAL_INFUSION_INFO)) + } + + @Test + fun `deleteTempBasalInfusionInfo removes the key and collapses the aggregate to absent`() { + assertThat(sut.updateTempBasalInfusionInfo(tempBasal())).isTrue() + + assertThat(sut.deleteTempBasalInfusionInfo()).isTrue() + + assertThat(store.containsKey(PrefEnvConfig.TEMP_BASAL_INFUSION_INFO)).isFalse() + assertThat(sut.getInfusionInfoBySync()).isNull() + verify(prefManager).remove(eq(PrefEnvConfig.TEMP_BASAL_INFUSION_INFO)) + } + + @Test + fun `deleteImmeBolusInfusionInfo removes the key and collapses the aggregate to absent`() { + assertThat(sut.updateImmeBolusInfusionInfo(immeBolus())).isTrue() + + assertThat(sut.deleteImmeBolusInfusionInfo()).isTrue() + + assertThat(store.containsKey(PrefEnvConfig.IMME_BOLUS_INFUSION_INFO)).isFalse() + assertThat(sut.getInfusionInfoBySync()).isNull() + verify(prefManager).remove(eq(PrefEnvConfig.IMME_BOLUS_INFUSION_INFO)) + } + + @Test + fun `deleteExtendBolusInfusionInfo removes the key and collapses the aggregate to absent`() { + assertThat(sut.updateExtendBolusInfusionInfo(extendBolus())).isTrue() + + assertThat(sut.deleteExtendBolusInfusionInfo()).isTrue() + + assertThat(store.containsKey(PrefEnvConfig.EXTEND_BOLUS_INFUSION_INFO)).isFalse() + assertThat(sut.getInfusionInfoBySync()).isNull() + verify(prefManager).remove(eq(PrefEnvConfig.EXTEND_BOLUS_INFUSION_INFO)) + } + + @Test + fun `deleteBasalInfusionInfo keeps the aggregate when other records remain`() { + assertThat(sut.updateBasalInfusionInfo(basal())).isTrue() + assertThat(sut.updateTempBasalInfusionInfo(tempBasal())).isTrue() + + assertThat(sut.deleteBasalInfusionInfo()).isTrue() + + val agg = sut.getInfusionInfoBySync()!! + assertThat(agg.basalInfusionInfo).isNull() + assertThat(agg.tempBasalInfusionInfo).isEqualTo(tempBasal()) + } + + @Test + fun `deleteBasalInfusionInfo on an absent aggregate stays absent and still succeeds`() { + // No prior state: the subject has never been populated (value == null branch). + assertThat(sut.deleteBasalInfusionInfo()).isTrue() + + assertThat(sut.getInfusionInfoBySync()).isNull() + verify(prefManager).remove(eq(PrefEnvConfig.BASAL_INFUSION_INFO)) + } + + @Test + fun `deleteBasalInfusionInfo returns false when the remove throws`() { + doThrow(RuntimeException("remove failed")).whenever(prefManager).remove(any()) + + assertThat(sut.deleteBasalInfusionInfo()).isFalse() + } + + // ---- deleteInfusionInfo (all) ---------------------------------------------------------- + + @Test + fun `deleteInfusionInfo removes all four keys and emits an absent aggregate`() { + assertThat(sut.updateBasalInfusionInfo(basal())).isTrue() + assertThat(sut.updateTempBasalInfusionInfo(tempBasal())).isTrue() + assertThat(sut.updateImmeBolusInfusionInfo(immeBolus())).isTrue() + assertThat(sut.updateExtendBolusInfusionInfo(extendBolus())).isTrue() + + assertThat(sut.deleteInfusionInfo()).isTrue() + + assertThat(store).isEmpty() + assertThat(sut.getInfusionInfoBySync()).isNull() + verify(prefManager).remove(eq(PrefEnvConfig.BASAL_INFUSION_INFO)) + verify(prefManager).remove(eq(PrefEnvConfig.TEMP_BASAL_INFUSION_INFO)) + verify(prefManager).remove(eq(PrefEnvConfig.IMME_BOLUS_INFUSION_INFO)) + verify(prefManager).remove(eq(PrefEnvConfig.EXTEND_BOLUS_INFUSION_INFO)) + } + + @Test + fun `deleteInfusionInfo returns false when a remove throws`() { + doThrow(RuntimeException("remove failed")).whenever(prefManager).remove(any()) + + assertThat(sut.deleteInfusionInfo()).isFalse() + } + + @Test + fun `deleteInfusionInfo emits an absent optional on the observable`() { + assertThat(sut.updateBasalInfusionInfo(basal())).isTrue() + val observer = sut.getInfusionInfo().test() + + assertThat(sut.deleteInfusionInfo()).isTrue() + + assertThat(observer.values().last().isPresent).isFalse() + observer.dispose() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoPatchInfoDaoImplTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoPatchInfoDaoImplTest.kt new file mode 100644 index 000000000000..40b65679f45a --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoPatchInfoDaoImplTest.kt @@ -0,0 +1,257 @@ +package app.aaps.pump.carelevo.data.dao + +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.pump.carelevo.config.PrefEnvConfig +import app.aaps.pump.carelevo.data.common.CarelevoGsonHelper +import app.aaps.pump.carelevo.data.model.entities.CarelevoPatchInfoEntity +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +/** + * Pure-JVM round-trip coverage for [CarelevoPatchInfoDaoImpl]. + * + * The DAO persists a single JSON blob under [PrefEnvConfig.PATCH_INFO] via [SP] and caches the + * parsed entity in an internal `BehaviorSubject`. We back the mocked [SP] with an in-memory map (the + * "fake preferences" approach) so save/load/update/delete really round-trip through the real gson + * used by production code, and we spawn a fresh DAO instance sharing the same store to exercise the + * cold-read (cache-miss) branches. + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoPatchInfoDaoImplTest { + + @Mock lateinit var sp: SP + + private val store = mutableMapOf() + + private lateinit var sut: CarelevoPatchInfoDaoImpl + + private fun samplePatch( + address: String = "AA:BB:CC:DD:EE:FF", + createdAt: String = "2026-07-16T10:00:00", + updatedAt: String = "2026-07-16T11:00:00", + insulinRemain: Double? = 60.0, + mode: Int? = 1 + ): CarelevoPatchInfoEntity = + CarelevoPatchInfoEntity( + address = address, + createdAt = createdAt, + updatedAt = updatedAt, + manufactureNumber = "CARELEVO-TEST-001", + firmwareVersion = "1.2.3", + insulinAmount = 200, + insulinRemain = insulinRemain, + isConnected = true, + mode = mode, + bolusActionSeq = 3, + pumpState = 0 + ) + + private fun storeJson(entity: CarelevoPatchInfoEntity) { + store[PrefEnvConfig.PATCH_INFO] = CarelevoGsonHelper.sharedGson().toJson(entity) + } + + @BeforeEach + fun setUp() { + whenever(sp.getString(any(), any())).thenAnswer { inv -> + val key = inv.getArgument(0) + val def = inv.getArgument(1) + store[key] ?: def + } + doAnswer { inv -> + store[inv.getArgument(0)] = inv.getArgument(1) + null + }.whenever(sp).putString(any(), any()) + doAnswer { inv -> + store.remove(inv.getArgument(0)) + null + }.whenever(sp).remove(any()) + + sut = CarelevoPatchInfoDaoImpl(sp) + } + + // region getPatchInfo (Observable) + + @Test + fun `getPatchInfo emits the stored entity when preferences hold valid JSON`() { + storeJson(samplePatch()) + + val emitted = sut.getPatchInfo().blockingFirst() + + assertThat(emitted.isPresent).isTrue() + assertThat(emitted.get()).isEqualTo(samplePatch()) + } + + @Test + fun `getPatchInfo emits empty when the preference is absent`() { + val emitted = sut.getPatchInfo().blockingFirst() + + assertThat(emitted.isPresent).isFalse() + } + + @Test + fun `getPatchInfo emits empty when the stored JSON is malformed`() { + store[PrefEnvConfig.PATCH_INFO] = "{{{not-valid-json" + + val emitted = sut.getPatchInfo().blockingFirst() + + assertThat(emitted.isPresent).isFalse() + } + + @Test + fun `getPatchInfo reads preferences only once and serves the cached subject afterwards`() { + storeJson(samplePatch()) + + sut.getPatchInfo().blockingFirst() + sut.getPatchInfo().blockingFirst() + + verify(sp, times(1)).getString(any(), any()) + } + + // endregion + + // region getPatchInfoBySync + + @Test + fun `getPatchInfoBySync returns the stored entity when preferences hold valid JSON`() { + storeJson(samplePatch()) + + assertThat(sut.getPatchInfoBySync()).isEqualTo(samplePatch()) + } + + @Test + fun `getPatchInfoBySync returns null when the preference is absent`() { + assertThat(sut.getPatchInfoBySync()).isNull() + } + + @Test + fun `getPatchInfoBySync returns null when the stored JSON is malformed`() { + store[PrefEnvConfig.PATCH_INFO] = "@@not-json@@" + + assertThat(sut.getPatchInfoBySync()).isNull() + } + + @Test + fun `getPatchInfoBySync reads preferences only once and serves the cached value afterwards`() { + storeJson(samplePatch()) + + sut.getPatchInfoBySync() + sut.getPatchInfoBySync() + + verify(sp, times(1)).getString(any(), any()) + } + + // endregion + + // region updatePatchInfo + + @Test + fun `updatePatchInfo persists the JSON blob and returns true`() { + val info = samplePatch() + + assertThat(sut.updatePatchInfo(info)).isTrue() + + verify(sp).putString(any(), any()) + assertThat(store[PrefEnvConfig.PATCH_INFO]) + .isEqualTo(CarelevoGsonHelper.sharedGson().toJson(info)) + } + + @Test + fun `updatePatchInfo refreshes the cache so later reads do not touch preferences`() { + val info = samplePatch() + + assertThat(sut.updatePatchInfo(info)).isTrue() + assertThat(sut.getPatchInfoBySync()).isEqualTo(info) + assertThat(sut.getPatchInfo().blockingFirst().get()).isEqualTo(info) + + verify(sp, never()).getString(any(), any()) + } + + @Test + fun `updatePatchInfo returns false when persisting throws`() { + doThrow(RuntimeException("disk full")).whenever(sp).putString(any(), any()) + + assertThat(sut.updatePatchInfo(samplePatch())).isFalse() + } + + // endregion + + // region deletePatchInfo + + @Test + fun `deletePatchInfo removes the preference and returns true`() { + sut.updatePatchInfo(samplePatch()) + + assertThat(sut.deletePatchInfo()).isTrue() + + verify(sp).remove(any()) + assertThat(store).doesNotContainKey(PrefEnvConfig.PATCH_INFO) + } + + @Test + fun `deletePatchInfo clears the cache so later reads return null without touching preferences`() { + sut.updatePatchInfo(samplePatch()) + + assertThat(sut.deletePatchInfo()).isTrue() + assertThat(sut.getPatchInfoBySync()).isNull() + + verify(sp, never()).getString(any(), any()) + } + + @Test + fun `deletePatchInfo returns false when removal throws`() { + doThrow(RuntimeException("io error")).whenever(sp).remove(any()) + + assertThat(sut.deletePatchInfo()).isFalse() + } + + // endregion + + // region round-trip across DAO instances (cold read / cache-miss branches) + + @Test + fun `saved patch info is loadable by a fresh DAO instance via getPatchInfoBySync`() { + val info = samplePatch() + sut.updatePatchInfo(info) + + val fresh = CarelevoPatchInfoDaoImpl(sp) + + assertThat(fresh.getPatchInfoBySync()).isEqualTo(info) + } + + @Test + fun `saved patch info is loadable by a fresh DAO instance via getPatchInfo observable`() { + val info = samplePatch(insulinRemain = null, mode = null) + sut.updatePatchInfo(info) + + val fresh = CarelevoPatchInfoDaoImpl(sp) + + assertThat(fresh.getPatchInfo().blockingFirst().get()).isEqualTo(info) + } + + @Test + fun `deleted patch info reads back as null on a fresh DAO instance`() { + sut.updatePatchInfo(samplePatch()) + sut.deletePatchInfo() + + val fresh = CarelevoPatchInfoDaoImpl(sp) + + assertThat(fresh.getPatchInfoBySync()).isNull() + assertThat(fresh.getPatchInfo().blockingFirst().isPresent).isFalse() + } + + // endregion +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoUserSettingInfoDaoImplTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoUserSettingInfoDaoImplTest.kt new file mode 100644 index 000000000000..836dcb031e08 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dao/CarelevoUserSettingInfoDaoImplTest.kt @@ -0,0 +1,256 @@ +package app.aaps.pump.carelevo.data.dao + +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.pump.carelevo.config.PrefEnvConfig +import app.aaps.pump.carelevo.data.common.CarelevoGsonHelper +import app.aaps.pump.carelevo.data.model.entities.CarelevoUserSettingInfoEntity +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +/** + * Pure-JVM round-trip coverage for [CarelevoUserSettingInfoDaoImpl]. + * + * The DAO persists a single JSON blob under [PrefEnvConfig.USER_SETTING_INFO] via [SP] and caches + * the parsed entity in an internal `BehaviorSubject`. We back the mocked [SP] with an in-memory map + * (the "fake preferences" approach) so save/load/update/delete really round-trip through the real + * gson used by production code, and we can spawn a fresh DAO instance sharing the same store to + * exercise the cold-read (cache-miss) branches. + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoUserSettingInfoDaoImplTest { + + @Mock lateinit var sp: SP + + private val store = mutableMapOf() + + private lateinit var sut: CarelevoUserSettingInfoDaoImpl + + private fun sampleSetting( + createdAt: String = "2026-07-16T10:00:00", + updatedAt: String = "2026-07-16T11:00:00", + lowInsulinNoticeAmount: Int? = 10, + maxBasalSpeed: Double? = 2.5, + maxBolusDose: Double? = 15.0 + ): CarelevoUserSettingInfoEntity = + CarelevoUserSettingInfoEntity( + createdAt = createdAt, + updatedAt = updatedAt, + lowInsulinNoticeAmount = lowInsulinNoticeAmount, + maxBasalSpeed = maxBasalSpeed, + maxBolusDose = maxBolusDose, + needLowInsulinNoticeAmountSyncPatch = true, + needMaxBasalSpeedSyncPatch = false, + needMaxBolusDoseSyncPatch = true + ) + + private fun storeJson(entity: CarelevoUserSettingInfoEntity) { + store[PrefEnvConfig.USER_SETTING_INFO] = CarelevoGsonHelper.sharedGson().toJson(entity) + } + + @BeforeEach + fun setUp() { + // Fake preferences backed by [store]: getString reads the map (falling back to the default), + // putString writes it, remove deletes it. + whenever(sp.getString(any(), any())).thenAnswer { inv -> + val key = inv.getArgument(0) + val def = inv.getArgument(1) + store[key] ?: def + } + doAnswer { inv -> + store[inv.getArgument(0)] = inv.getArgument(1) + null + }.whenever(sp).putString(any(), any()) + doAnswer { inv -> + store.remove(inv.getArgument(0)) + null + }.whenever(sp).remove(any()) + + sut = CarelevoUserSettingInfoDaoImpl(sp) + } + + // region getUserSetting (Observable) + + @Test + fun `getUserSetting emits the stored entity when preferences hold valid JSON`() { + storeJson(sampleSetting()) + + val emitted = sut.getUserSetting().blockingFirst() + + assertThat(emitted.isPresent).isTrue() + assertThat(emitted.get()).isEqualTo(sampleSetting()) + } + + @Test + fun `getUserSetting emits empty when the preference is absent`() { + val emitted = sut.getUserSetting().blockingFirst() + + assertThat(emitted.isPresent).isFalse() + } + + @Test + fun `getUserSetting emits empty when the stored JSON is malformed`() { + store[PrefEnvConfig.USER_SETTING_INFO] = "{{{not-valid-json" + + val emitted = sut.getUserSetting().blockingFirst() + + assertThat(emitted.isPresent).isFalse() + } + + @Test + fun `getUserSetting reads preferences only once and serves the cached subject afterwards`() { + storeJson(sampleSetting()) + + sut.getUserSetting().blockingFirst() + sut.getUserSetting().blockingFirst() + + verify(sp, times(1)).getString(any(), any()) + } + + // endregion + + // region getUserSettingBySync + + @Test + fun `getUserSettingBySync returns the stored entity when preferences hold valid JSON`() { + storeJson(sampleSetting()) + + assertThat(sut.getUserSettingBySync()).isEqualTo(sampleSetting()) + } + + @Test + fun `getUserSettingBySync returns null when the preference is absent`() { + assertThat(sut.getUserSettingBySync()).isNull() + } + + @Test + fun `getUserSettingBySync returns null when the stored JSON is malformed`() { + store[PrefEnvConfig.USER_SETTING_INFO] = "@@not-json@@" + + assertThat(sut.getUserSettingBySync()).isNull() + } + + @Test + fun `getUserSettingBySync reads preferences only once and serves the cached value afterwards`() { + storeJson(sampleSetting()) + + sut.getUserSettingBySync() + sut.getUserSettingBySync() + + verify(sp, times(1)).getString(any(), any()) + } + + // endregion + + // region updateUserSetting + + @Test + fun `updateUserSetting persists the JSON blob and returns true`() { + val setting = sampleSetting() + + assertThat(sut.updateUserSetting(setting)).isTrue() + + verify(sp).putString(any(), any()) + assertThat(store[PrefEnvConfig.USER_SETTING_INFO]) + .isEqualTo(CarelevoGsonHelper.sharedGson().toJson(setting)) + } + + @Test + fun `updateUserSetting refreshes the cache so later reads do not touch preferences`() { + val setting = sampleSetting() + + assertThat(sut.updateUserSetting(setting)).isTrue() + assertThat(sut.getUserSettingBySync()).isEqualTo(setting) + assertThat(sut.getUserSetting().blockingFirst().get()).isEqualTo(setting) + + verify(sp, never()).getString(any(), any()) + } + + @Test + fun `updateUserSetting returns false when persisting throws`() { + doThrow(RuntimeException("disk full")).whenever(sp).putString(any(), any()) + + assertThat(sut.updateUserSetting(sampleSetting())).isFalse() + } + + // endregion + + // region deleteUserSetting + + @Test + fun `deleteUserSetting removes the preference and returns true`() { + sut.updateUserSetting(sampleSetting()) + + assertThat(sut.deleteUserSetting()).isTrue() + + verify(sp).remove(any()) + assertThat(store).doesNotContainKey(PrefEnvConfig.USER_SETTING_INFO) + } + + @Test + fun `deleteUserSetting clears the cache so later reads return null without touching preferences`() { + sut.updateUserSetting(sampleSetting()) + + assertThat(sut.deleteUserSetting()).isTrue() + assertThat(sut.getUserSettingBySync()).isNull() + + verify(sp, never()).getString(any(), any()) + } + + @Test + fun `deleteUserSetting returns false when removal throws`() { + doThrow(RuntimeException("io error")).whenever(sp).remove(any()) + + assertThat(sut.deleteUserSetting()).isFalse() + } + + // endregion + + // region round-trip across DAO instances (cold read / cache-miss branches) + + @Test + fun `saved setting is loadable by a fresh DAO instance via getUserSettingBySync`() { + val setting = sampleSetting() + sut.updateUserSetting(setting) + + val fresh = CarelevoUserSettingInfoDaoImpl(sp) + + assertThat(fresh.getUserSettingBySync()).isEqualTo(setting) + } + + @Test + fun `saved setting is loadable by a fresh DAO instance via getUserSetting observable`() { + val setting = sampleSetting(maxBolusDose = null) + sut.updateUserSetting(setting) + + val fresh = CarelevoUserSettingInfoDaoImpl(sp) + + assertThat(fresh.getUserSetting().blockingFirst().get()).isEqualTo(setting) + } + + @Test + fun `deleted setting reads back as null on a fresh DAO instance`() { + sut.updateUserSetting(sampleSetting()) + sut.deleteUserSetting() + + val fresh = CarelevoUserSettingInfoDaoImpl(sp) + + assertThat(fresh.getUserSettingBySync()).isNull() + assertThat(fresh.getUserSetting().blockingFirst().isPresent).isFalse() + } + + // endregion +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoAlarmInfoLocalDataSourceImplTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoAlarmInfoLocalDataSourceImplTest.kt new file mode 100644 index 000000000000..cb87304aaea0 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoAlarmInfoLocalDataSourceImplTest.kt @@ -0,0 +1,120 @@ +package app.aaps.pump.carelevo.data.dataSource.local + +import app.aaps.pump.carelevo.data.dao.CarelevoAlarmInfoDao +import app.aaps.pump.carelevo.data.model.entities.CarelevoAlarmInfoEntity +import app.aaps.pump.carelevo.domain.type.AlarmCause +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.Optional + +/** + * Delegation tests for [CarelevoAlarmInfoLocalDataSourceImpl] — a thin pass-through over + * [CarelevoAlarmInfoDao]. Each public method must forward to the matching DAO method and return the + * DAO's own stream unmodified. + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoAlarmInfoLocalDataSourceImplTest { + + @Mock lateinit var dao: CarelevoAlarmInfoDao + + private lateinit var sut: CarelevoAlarmInfoLocalDataSourceImpl + + private val created = "2026-07-16T12:00:00.000Z" + private val updated = "2026-07-16T12:05:00.000Z" + + @BeforeEach + fun setUp() { + sut = CarelevoAlarmInfoLocalDataSourceImpl(dao) + } + + private fun sampleEntity(id: String = "a-1") = CarelevoAlarmInfoEntity( + alarmId = id, alarmType = 1, cause = AlarmCause.ALARM_ALERT_LOW_BATTERY, value = 3, + createdAt = created, updatedAt = updated, acknowledged = false + ) + + @Test + fun `observeAlarms returns the dao stream unchanged`() { + val stream = Observable.just(Optional.of(listOf(sampleEntity()))) + whenever(dao.getAlarms()).thenReturn(stream) + + assertThat(sut.observeAlarms()).isSameInstanceAs(stream) + verify(dao).getAlarms() + } + + @Test + fun `observeAlarms emits the dao value`() { + whenever(dao.getAlarms()).thenReturn(Observable.just(Optional.of(listOf(sampleEntity())))) + + val emitted = sut.observeAlarms().blockingFirst() + + assertThat(emitted.get()).hasSize(1) + assertThat(emitted.get().first().alarmId).isEqualTo("a-1") + } + + @Test + fun `getAlarmsOnce returns the dao single unchanged`() { + val single = Single.just(Optional.of(listOf(sampleEntity()))) + whenever(dao.getAlarmsOnce()).thenReturn(single) + + assertThat(sut.getAlarmsOnce()).isSameInstanceAs(single) + verify(dao).getAlarmsOnce() + } + + @Test + fun `getAlarmsOnce carries an empty optional through`() { + whenever(dao.getAlarmsOnce()).thenReturn(Single.just(Optional.empty>())) + + assertThat(sut.getAlarmsOnce().blockingGet().isPresent).isFalse() + } + + @Test + fun `setAlarms forwards the list to the dao`() { + val list = listOf(sampleEntity("a-1"), sampleEntity("a-2")) + val completable = Completable.complete() + whenever(dao.setAlarms(list)).thenReturn(completable) + + assertThat(sut.setAlarms(list)).isSameInstanceAs(completable) + verify(dao).setAlarms(eq(list)) + } + + @Test + fun `upsertAlarm forwards the entity to the dao`() { + val entity = sampleEntity() + val completable = Completable.complete() + whenever(dao.upsertAlarm(entity)).thenReturn(completable) + + assertThat(sut.upsertAlarm(entity)).isSameInstanceAs(completable) + verify(dao).upsertAlarm(eq(entity)) + } + + @Test + fun `removeAlarm forwards the id to the dao`() { + val completable = Completable.complete() + whenever(dao.removeAlarm("a-9")).thenReturn(completable) + + assertThat(sut.removeAlarm("a-9")).isSameInstanceAs(completable) + verify(dao).removeAlarm(eq("a-9")) + } + + @Test + fun `clearAlarms delegates to the dao`() { + val completable = Completable.complete() + whenever(dao.clearAlarms()).thenReturn(completable) + + assertThat(sut.clearAlarms()).isSameInstanceAs(completable) + verify(dao).clearAlarms() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoInfusionInfoDataSourceImplTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoInfusionInfoDataSourceImplTest.kt new file mode 100644 index 000000000000..5d4af50dba22 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoInfusionInfoDataSourceImplTest.kt @@ -0,0 +1,169 @@ +package app.aaps.pump.carelevo.data.dataSource.local + +import app.aaps.pump.carelevo.data.dao.CarelevoInfusionInfoDao +import app.aaps.pump.carelevo.data.model.entities.CarelevoBasalInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoExtendBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoImmeBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoTempBasalInfusionInfoEntity +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Observable +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.Optional + +/** + * Delegation tests for [CarelevoInfusionInfoDataSourceImpl] — a thin pass-through over + * [CarelevoInfusionInfoDao]. No mapping happens here, so every method must simply forward its + * argument and return the DAO's result verbatim (including the boolean success flags). + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoInfusionInfoDataSourceImplTest { + + @Mock lateinit var dao: CarelevoInfusionInfoDao + + private lateinit var sut: CarelevoInfusionInfoDataSourceImpl + + private val created = "2026-07-16T12:00:00.000Z" + private val updated = "2026-07-16T12:05:00.000Z" + + @BeforeEach + fun setUp() { + sut = CarelevoInfusionInfoDataSourceImpl(dao) + } + + private fun basal() = CarelevoBasalInfusionInfoEntity( + infusionId = "b-1", address = "AA", mode = 1, createdAt = created, updatedAt = updated, + segments = emptyList(), isStop = false + ) + + private fun tempBasal() = CarelevoTempBasalInfusionInfoEntity( + infusionId = "t-1", address = "AA", mode = 2, createdAt = created, updatedAt = updated + ) + + private fun imme() = CarelevoImmeBolusInfusionInfoEntity( + infusionId = "i-1", address = "AA", mode = 3, createdAt = created, updatedAt = updated + ) + + private fun extend() = CarelevoExtendBolusInfusionInfoEntity( + infusionId = "e-1", address = "AA", mode = 5, createdAt = created, updatedAt = updated + ) + + @Test + fun `getInfusionInfo returns the dao stream unchanged`() { + val stream = Observable.just(Optional.of(CarelevoInfusionInfoEntity())) + whenever(dao.getInfusionInfo()).thenReturn(stream) + + assertThat(sut.getInfusionInfo()).isSameInstanceAs(stream) + verify(dao).getInfusionInfo() + } + + @Test + fun `getInfusionInfoBySync forwards the dao entity`() { + val entity = CarelevoInfusionInfoEntity(basalInfusionInfo = basal()) + whenever(dao.getInfusionInfoBySync()).thenReturn(entity) + + assertThat(sut.getInfusionInfoBySync()).isSameInstanceAs(entity) + verify(dao).getInfusionInfoBySync() + } + + @Test + fun `getInfusionInfoBySync forwards a null`() { + whenever(dao.getInfusionInfoBySync()).thenReturn(null) + + assertThat(sut.getInfusionInfoBySync()).isNull() + } + + @Test + fun `updateBasalInfusionInfo forwards and returns true`() { + val info = basal() + whenever(dao.updateBasalInfusionInfo(info)).thenReturn(true) + + assertThat(sut.updateBasalInfusionInfo(info)).isTrue() + verify(dao).updateBasalInfusionInfo(eq(info)) + } + + @Test + fun `updateBasalInfusionInfo forwards and returns false`() { + val info = basal() + whenever(dao.updateBasalInfusionInfo(info)).thenReturn(false) + + assertThat(sut.updateBasalInfusionInfo(info)).isFalse() + } + + @Test + fun `updateTempBasalInfusionInfo forwards and returns dao result`() { + val info = tempBasal() + whenever(dao.updateTempBasalInfusionInfo(info)).thenReturn(true) + + assertThat(sut.updateTempBasalInfusionInfo(info)).isTrue() + verify(dao).updateTempBasalInfusionInfo(eq(info)) + } + + @Test + fun `updateImmeBolusInfusionInfo forwards and returns dao result`() { + val info = imme() + whenever(dao.updateImmeBolusInfusionInfo(info)).thenReturn(true) + + assertThat(sut.updateImmeBolusInfusionInfo(info)).isTrue() + verify(dao).updateImmeBolusInfusionInfo(eq(info)) + } + + @Test + fun `updateExtendBolusInfusionInfo forwards and returns dao result`() { + val info = extend() + whenever(dao.updateExtendBolusInfusionInfo(info)).thenReturn(false) + + assertThat(sut.updateExtendBolusInfusionInfo(info)).isFalse() + verify(dao).updateExtendBolusInfusionInfo(eq(info)) + } + + @Test + fun `deleteBasalInfusionInfo delegates and returns dao result`() { + whenever(dao.deleteBasalInfusionInfo()).thenReturn(true) + + assertThat(sut.deleteBasalInfusionInfo()).isTrue() + verify(dao).deleteBasalInfusionInfo() + } + + @Test + fun `deleteTempBasalInfusionInfo delegates and returns dao result`() { + whenever(dao.deleteTempBasalInfusionInfo()).thenReturn(false) + + assertThat(sut.deleteTempBasalInfusionInfo()).isFalse() + verify(dao).deleteTempBasalInfusionInfo() + } + + @Test + fun `deleteImmeBolusInfusionInfo delegates and returns dao result`() { + whenever(dao.deleteImmeBolusInfusionInfo()).thenReturn(true) + + assertThat(sut.deleteImmeBolusInfusionInfo()).isTrue() + verify(dao).deleteImmeBolusInfusionInfo() + } + + @Test + fun `deleteExtendBolusInfusionInfo delegates and returns dao result`() { + whenever(dao.deleteExtendBolusInfusionInfo()).thenReturn(true) + + assertThat(sut.deleteExtendBolusInfusionInfo()).isTrue() + verify(dao).deleteExtendBolusInfusionInfo() + } + + @Test + fun `deleteInfusionInfo delegates and returns dao result`() { + whenever(dao.deleteInfusionInfo()).thenReturn(true) + + assertThat(sut.deleteInfusionInfo()).isTrue() + verify(dao).deleteInfusionInfo() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoPatchInfoDataSourceImplTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoPatchInfoDataSourceImplTest.kt new file mode 100644 index 000000000000..0b98a1859d49 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoPatchInfoDataSourceImplTest.kt @@ -0,0 +1,93 @@ +package app.aaps.pump.carelevo.data.dataSource.local + +import app.aaps.pump.carelevo.data.dao.CarelevoPatchInfoDao +import app.aaps.pump.carelevo.data.model.entities.CarelevoPatchInfoEntity +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Observable +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.Optional + +/** + * Delegation tests for [CarelevoPatchInfoDataSourceImpl] — a thin pass-through over + * [CarelevoPatchInfoDao]. Every method forwards to the DAO and returns its result verbatim. + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoPatchInfoDataSourceImplTest { + + @Mock lateinit var dao: CarelevoPatchInfoDao + + private lateinit var sut: CarelevoPatchInfoDataSourceImpl + + private val created = "2026-07-16T12:00:00.000Z" + private val updated = "2026-07-16T12:05:00.000Z" + + @BeforeEach + fun setUp() { + sut = CarelevoPatchInfoDataSourceImpl(dao) + } + + private fun entity() = CarelevoPatchInfoEntity( + address = "AA:BB:CC:DD:EE:FF", createdAt = created, updatedAt = updated, + manufactureNumber = "CARELEVO-001", insulinRemain = 60.0 + ) + + @Test + fun `getPatchInfo returns the dao stream unchanged`() { + val stream = Observable.just(Optional.of(entity())) + whenever(dao.getPatchInfo()).thenReturn(stream) + + assertThat(sut.getPatchInfo()).isSameInstanceAs(stream) + verify(dao).getPatchInfo() + } + + @Test + fun `getPatchInfoBySync forwards the dao entity`() { + val entity = entity() + whenever(dao.getPatchInfoBySync()).thenReturn(entity) + + assertThat(sut.getPatchInfoBySync()).isSameInstanceAs(entity) + verify(dao).getPatchInfoBySync() + } + + @Test + fun `getPatchInfoBySync forwards a null`() { + whenever(dao.getPatchInfoBySync()).thenReturn(null) + + assertThat(sut.getPatchInfoBySync()).isNull() + } + + @Test + fun `updatePatchInfo forwards the entity and returns true`() { + val entity = entity() + whenever(dao.updatePatchInfo(entity)).thenReturn(true) + + assertThat(sut.updatePatchInfo(entity)).isTrue() + verify(dao).updatePatchInfo(eq(entity)) + } + + @Test + fun `updatePatchInfo forwards the entity and returns false`() { + val entity = entity() + whenever(dao.updatePatchInfo(entity)).thenReturn(false) + + assertThat(sut.updatePatchInfo(entity)).isFalse() + } + + @Test + fun `deletePatchInfo delegates and returns dao result`() { + whenever(dao.deletePatchInfo()).thenReturn(true) + + assertThat(sut.deletePatchInfo()).isTrue() + verify(dao).deletePatchInfo() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoUserSettingInfoDataSourceImplTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoUserSettingInfoDataSourceImplTest.kt new file mode 100644 index 000000000000..93902967788f --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/dataSource/local/CarelevoUserSettingInfoDataSourceImplTest.kt @@ -0,0 +1,94 @@ +package app.aaps.pump.carelevo.data.dataSource.local + +import app.aaps.pump.carelevo.data.dao.CarelevoUserSettingInfoDao +import app.aaps.pump.carelevo.data.model.entities.CarelevoUserSettingInfoEntity +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Observable +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.Optional + +/** + * Delegation tests for [CarelevoUserSettingInfoDataSourceImpl] — a thin pass-through over + * [CarelevoUserSettingInfoDao]. Note the data-source method names differ from the DAO names + * (`getUserSettingInfo` -> `getUserSetting`, etc.), so these tests also guard the correct wiring. + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoUserSettingInfoDataSourceImplTest { + + @Mock lateinit var dao: CarelevoUserSettingInfoDao + + private lateinit var sut: CarelevoUserSettingInfoDataSourceImpl + + private val created = "2026-07-16T12:00:00.000Z" + private val updated = "2026-07-16T12:05:00.000Z" + + @BeforeEach + fun setUp() { + sut = CarelevoUserSettingInfoDataSourceImpl(dao) + } + + private fun entity() = CarelevoUserSettingInfoEntity( + createdAt = created, updatedAt = updated, lowInsulinNoticeAmount = 10, + maxBasalSpeed = 3.0, maxBolusDose = 15.0 + ) + + @Test + fun `getUserSettingInfo returns the dao stream unchanged`() { + val stream = Observable.just(Optional.of(entity())) + whenever(dao.getUserSetting()).thenReturn(stream) + + assertThat(sut.getUserSettingInfo()).isSameInstanceAs(stream) + verify(dao).getUserSetting() + } + + @Test + fun `getUserSettingInfoBySync forwards the dao entity`() { + val entity = entity() + whenever(dao.getUserSettingBySync()).thenReturn(entity) + + assertThat(sut.getUserSettingInfoBySync()).isSameInstanceAs(entity) + verify(dao).getUserSettingBySync() + } + + @Test + fun `getUserSettingInfoBySync forwards a null`() { + whenever(dao.getUserSettingBySync()).thenReturn(null) + + assertThat(sut.getUserSettingInfoBySync()).isNull() + } + + @Test + fun `updateUserSettingInfo forwards the entity and returns true`() { + val entity = entity() + whenever(dao.updateUserSetting(entity)).thenReturn(true) + + assertThat(sut.updateUserSettingInfo(entity)).isTrue() + verify(dao).updateUserSetting(eq(entity)) + } + + @Test + fun `updateUserSettingInfo forwards the entity and returns false`() { + val entity = entity() + whenever(dao.updateUserSetting(entity)).thenReturn(false) + + assertThat(sut.updateUserSettingInfo(entity)).isFalse() + } + + @Test + fun `deleteUserSettingInfo delegates and returns dao result`() { + whenever(dao.deleteUserSetting()).thenReturn(true) + + assertThat(sut.deleteUserSettingInfo()).isTrue() + verify(dao).deleteUserSetting() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoAlarmInfoMapperTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoAlarmInfoMapperTest.kt new file mode 100644 index 000000000000..4f9ab637795f --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoAlarmInfoMapperTest.kt @@ -0,0 +1,195 @@ +package app.aaps.pump.carelevo.data.mapper + +import app.aaps.pump.carelevo.data.model.entities.CarelevoAlarmInfoEntity +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * Pure-logic unit tests for the entity <-> domain mappers in `CarelevoAlarmInfoMapper.kt`. + * + * Covers both directions, the `Int` <-> [AlarmType] code translation for every tier (including the + * unknown-code fallback), the nullable `value` branch, and the deliberate `occurrenceCount` loss on + * the domain -> entity path (the mapper never forwards it, so the entity default of 1 is used). + */ +internal class CarelevoAlarmInfoMapperTest { + + private val created = "2026-07-16T12:00:00.000Z" + private val updated = "2026-07-16T12:05:00.000Z" + + // region entity -> domain + + @Test + fun `entity to domain maps every scalar field`() { + val entity = CarelevoAlarmInfoEntity( + alarmId = "a-1", alarmType = 0, cause = AlarmCause.ALARM_WARNING_LOW_INSULIN, + value = 12, createdAt = created, updatedAt = updated, acknowledged = true, occurrenceCount = 4 + ) + val domain = entity.transformToDomainModel() + assertThat(domain.alarmId).isEqualTo("a-1") + assertThat(domain.alarmType).isEqualTo(AlarmType.WARNING) + assertThat(domain.cause).isEqualTo(AlarmCause.ALARM_WARNING_LOW_INSULIN) + assertThat(domain.value).isEqualTo(12) + assertThat(domain.createdAt).isEqualTo(created) + assertThat(domain.updatedAt).isEqualTo(updated) + assertThat(domain.isAcknowledged).isTrue() + assertThat(domain.occurrenceCount).isEqualTo(4) + } + + @Test + fun `entity to domain maps null value`() { + val entity = CarelevoAlarmInfoEntity( + "a-2", 2, AlarmCause.ALARM_NOTICE_LOW_INSULIN, value = null, createdAt = created, + updatedAt = updated, acknowledged = false + ) + val domain = entity.transformToDomainModel() + assertThat(domain.value).isNull() + assertThat(domain.isAcknowledged).isFalse() + } + + @Test + fun `entity to domain applies default occurrenceCount of one`() { + val entity = CarelevoAlarmInfoEntity( + "a-3", 1, AlarmCause.ALARM_ALERT_OUT_OF_INSULIN, value = 0, createdAt = created, + updatedAt = updated, acknowledged = false + ) + assertThat(entity.transformToDomainModel().occurrenceCount).isEqualTo(1) + } + + @Test + fun `entity to domain maps alarmType code 0 to WARNING`() { + assertThat(alarmEntityWithType(0).transformToDomainModel().alarmType).isEqualTo(AlarmType.WARNING) + } + + @Test + fun `entity to domain maps alarmType code 1 to ALERT`() { + assertThat(alarmEntityWithType(1).transformToDomainModel().alarmType).isEqualTo(AlarmType.ALERT) + } + + @Test + fun `entity to domain maps alarmType code 2 to NOTICE`() { + assertThat(alarmEntityWithType(2).transformToDomainModel().alarmType).isEqualTo(AlarmType.NOTICE) + } + + @Test + fun `entity to domain maps alarmType code 3 to UNKNOWN_TYPE`() { + assertThat(alarmEntityWithType(3).transformToDomainModel().alarmType).isEqualTo(AlarmType.UNKNOWN_TYPE) + } + + @Test + fun `entity to domain maps unrecognized alarmType code to UNKNOWN_TYPE`() { + assertThat(alarmEntityWithType(42).transformToDomainModel().alarmType).isEqualTo(AlarmType.UNKNOWN_TYPE) + } + + @Test + fun `entity to domain maps negative alarmType code to UNKNOWN_TYPE`() { + assertThat(alarmEntityWithType(-1).transformToDomainModel().alarmType).isEqualTo(AlarmType.UNKNOWN_TYPE) + } + + // endregion + + // region domain -> entity + + @Test + fun `domain to entity maps every forwarded field`() { + val domain = CarelevoAlarmInfo( + alarmId = "d-1", alarmType = AlarmType.ALERT, cause = AlarmCause.ALARM_ALERT_OUT_OF_INSULIN, + value = 7, createdAt = created, updatedAt = updated, isAcknowledged = true, occurrenceCount = 9 + ) + val entity = domain.transformToEntity() + assertThat(entity.alarmId).isEqualTo("d-1") + assertThat(entity.alarmType).isEqualTo(1) + assertThat(entity.cause).isEqualTo(AlarmCause.ALARM_ALERT_OUT_OF_INSULIN) + assertThat(entity.value).isEqualTo(7) + assertThat(entity.createdAt).isEqualTo(created) + assertThat(entity.updatedAt).isEqualTo(updated) + assertThat(entity.acknowledged).isTrue() + } + + @Test + fun `domain to entity maps null value`() { + val domain = CarelevoAlarmInfo( + "d-2", AlarmType.WARNING, AlarmCause.ALARM_WARNING_LOW_INSULIN, value = null, + createdAt = created, updatedAt = updated, isAcknowledged = false + ) + val entity = domain.transformToEntity() + assertThat(entity.value).isNull() + assertThat(entity.acknowledged).isFalse() + } + + @Test + fun `domain to entity does not forward occurrenceCount so entity default of one is used`() { + val domain = CarelevoAlarmInfo( + "d-3", AlarmType.NOTICE, AlarmCause.ALARM_NOTICE_LOW_INSULIN, value = null, + createdAt = created, updatedAt = updated, isAcknowledged = false, occurrenceCount = 5 + ) + assertThat(domain.transformToEntity().occurrenceCount).isEqualTo(1) + } + + @Test + fun `domain to entity maps WARNING to code 0`() { + assertThat(alarmDomainWithType(AlarmType.WARNING).transformToEntity().alarmType).isEqualTo(0) + } + + @Test + fun `domain to entity maps ALERT to code 1`() { + assertThat(alarmDomainWithType(AlarmType.ALERT).transformToEntity().alarmType).isEqualTo(1) + } + + @Test + fun `domain to entity maps NOTICE to code 2`() { + assertThat(alarmDomainWithType(AlarmType.NOTICE).transformToEntity().alarmType).isEqualTo(2) + } + + @Test + fun `domain to entity maps UNKNOWN_TYPE to code 3`() { + assertThat(alarmDomainWithType(AlarmType.UNKNOWN_TYPE).transformToEntity().alarmType).isEqualTo(3) + } + + // endregion + + // region round trips + + @Test + fun `entity round trips through domain when occurrenceCount is one`() { + val entity = CarelevoAlarmInfoEntity( + "r-1", 1, AlarmCause.ALARM_ALERT_LOW_BATTERY, value = 3, createdAt = created, + updatedAt = updated, acknowledged = true, occurrenceCount = 1 + ) + assertThat(entity.transformToDomainModel().transformToEntity()).isEqualTo(entity) + } + + @Test + fun `entity round trip collapses occurrenceCount above one to the default`() { + val entity = CarelevoAlarmInfoEntity( + "r-2", 0, AlarmCause.ALARM_WARNING_PUMP_CLOGGED, value = null, createdAt = created, + updatedAt = updated, acknowledged = false, occurrenceCount = 3 + ) + val back = entity.transformToDomainModel().transformToEntity() + assertThat(back.occurrenceCount).isEqualTo(1) + assertThat(back).isEqualTo(entity.copy(occurrenceCount = 1)) + } + + @Test + fun `domain round trips through entity when tier is known and occurrenceCount is one`() { + val domain = CarelevoAlarmInfo( + "r-3", AlarmType.NOTICE, AlarmCause.ALARM_NOTICE_BG_CHECK, value = 5, createdAt = created, + updatedAt = updated, isAcknowledged = true, occurrenceCount = 1 + ) + assertThat(domain.transformToEntity().transformToDomainModel()).isEqualTo(domain) + } + + // endregion + + private fun alarmEntityWithType(type: Int) = CarelevoAlarmInfoEntity( + alarmId = "x", alarmType = type, cause = AlarmCause.ALARM_UNKNOWN, value = null, + createdAt = created, updatedAt = updated, acknowledged = false + ) + + private fun alarmDomainWithType(type: AlarmType) = CarelevoAlarmInfo( + alarmId = "x", alarmType = type, cause = AlarmCause.ALARM_UNKNOWN, value = null, + createdAt = created, updatedAt = updated, isAcknowledged = false + ) +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoBtMapperTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoBtMapperTest.kt new file mode 100644 index 000000000000..1a1208334970 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoBtMapperTest.kt @@ -0,0 +1,509 @@ +package app.aaps.pump.carelevo.data.mapper + +import app.aaps.pump.carelevo.data.model.ble.ProtocolAdditionalBasalInfusionChangeRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolAdditionalBasalProgramSetRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolAdditionalPrimingRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolAlertMsgRptModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolAppAuthAckRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolAppAuthKeyAckRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolAppStatusRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolBasalInfusionChangeRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolBasalInfusionResumeRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolBasalInfusionStartRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolBasalProgramSetRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolBolusInfusionCancelRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolBuzzUsageChangeRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolCannulaInsertionAckRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolCannulaInsertionStatusRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolExtendBolusDelayRptModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolExtendBolusInfusionCancelRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolExtendBolusInfusionRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolImmeBolusInfusionRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolInfusionStatusInquiryRptModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolInfusionThresholdRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolMsgSolutionRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolNoticeMsgRptModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolNoticeThresholdRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchAddressRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchAlertAlarmSetRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchBuzzInspectionRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchDiscardRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchExpiryExtendRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchInformationInquiryDetailRptModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchInformationInquiryRptModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchInitRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchOperationDataRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchRecoveryRptModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPatchThresholdSetRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPumpResumeRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPumpStopRptModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolPumpStopRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolSafetyCheckRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolSetTimeRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolTempBasalInfusionCancelRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolTempBasalInfusionRspModel +import app.aaps.pump.carelevo.data.model.ble.ProtocolWarningMsgRptModel +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * Exhaustive field-mapping tests for every [transformToDomainModel] extension in CarelevoBtMapper. + * + * Each protocol model is fed with intentionally distinct field values so that a wrong-field wiring + * (e.g. mapping `insulinRemains` onto the wrong target property) fails loudly rather than passing on + * a coincidental match. + */ +internal class CarelevoBtMapperTest { + + @Test + fun `ProtocolSetTimeRspModel maps timestamp command result`() { + val out = ProtocolSetTimeRspModel(timestamp = 111L, command = 1, result = 7).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(111L) + assertThat(out.command).isEqualTo(1) + assertThat(out.result).isEqualTo(7) + } + + @Test + fun `ProtocolSafetyCheckRspModel maps insulinVolume to volume and duration`() { + val out = ProtocolSafetyCheckRspModel(timestamp = 222L, command = 2, result = 0, insulinVolume = 300, durationSeconds = 45).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(222L) + assertThat(out.command).isEqualTo(2) + assertThat(out.result).isEqualTo(0) + assertThat(out.volume).isEqualTo(300) + assertThat(out.durationSeconds).isEqualTo(45) + } + + @Test + fun `ProtocolAdditionalPrimingRspModel maps timestamp command result`() { + val out = ProtocolAdditionalPrimingRspModel(timestamp = 333L, command = 3, result = 1).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(333L) + assertThat(out.command).isEqualTo(3) + assertThat(out.result).isEqualTo(1) + } + + @Test + fun `ProtocolPatchAlertAlarmSetRspModel maps to SetAlertAlarmModelResponse`() { + val out = ProtocolPatchAlertAlarmSetRspModel(timestamp = 444L, command = 4, result = 2).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(444L) + assertThat(out.command).isEqualTo(4) + assertThat(out.result).isEqualTo(2) + } + + @Test + fun `ProtocolNoticeThresholdRspModel maps result and type`() { + val out = ProtocolNoticeThresholdRspModel(timestamp = 555L, command = 5, result = 0, type = 9).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(555L) + assertThat(out.command).isEqualTo(5) + assertThat(out.result).isEqualTo(0) + assertThat(out.type).isEqualTo(9) + } + + @Test + fun `ProtocolInfusionThresholdRspModel maps type and result`() { + val out = ProtocolInfusionThresholdRspModel(timestamp = 666L, command = 6, type = 8, result = 3).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(666L) + assertThat(out.command).isEqualTo(6) + assertThat(out.type).isEqualTo(8) + assertThat(out.result).isEqualTo(3) + } + + @Test + fun `ProtocolBuzzUsageChangeRspModel maps to SetBuzzModeResponse`() { + val out = ProtocolBuzzUsageChangeRspModel(timestamp = 777L, command = 7, result = 1).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(777L) + assertThat(out.command).isEqualTo(7) + assertThat(out.result).isEqualTo(1) + } + + @Test + fun `ProtocolCannulaInsertionStatusRspModel maps to CannulaInsertionResponse`() { + val out = ProtocolCannulaInsertionStatusRspModel(timestamp = 888L, command = 8, result = 4).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(888L) + assertThat(out.command).isEqualTo(8) + assertThat(out.result).isEqualTo(4) + } + + @Test + fun `ProtocolCannulaInsertionAckRspModel maps to CannulaInsertionAckResponse`() { + val out = ProtocolCannulaInsertionAckRspModel(timestamp = 999L, command = 9, result = 5).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(999L) + assertThat(out.command).isEqualTo(9) + assertThat(out.result).isEqualTo(5) + } + + @Test + fun `ProtocolPatchThresholdSetRspModel maps to ThresholdSetResponse`() { + val out = ProtocolPatchThresholdSetRspModel(timestamp = 1010L, command = 10, result = 6).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(1010L) + assertThat(out.command).isEqualTo(10) + assertThat(out.result).isEqualTo(6) + } + + @Test + fun `ProtocolPatchExpiryExtendRspModel maps to SetExpiryExtendResponse`() { + val out = ProtocolPatchExpiryExtendRspModel(timestamp = 1111L, command = 11, result = 7).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(1111L) + assertThat(out.command).isEqualTo(11) + assertThat(out.result).isEqualTo(7) + } + + @Test + fun `ProtocolPatchInformationInquiryRptModel maps serialNum`() { + val out = ProtocolPatchInformationInquiryRptModel(timestamp = 1212L, command = 12, result = 0, serialNum = "SN-12345").transformToDomainModel() + assertThat(out.timestamp).isEqualTo(1212L) + assertThat(out.command).isEqualTo(12) + assertThat(out.result).isEqualTo(0) + assertThat(out.serialNum).isEqualTo("SN-12345") + } + + @Test + fun `ProtocolPatchInformationInquiryDetailRptModel maps firmVersion bootDateTime modelName`() { + val out = ProtocolPatchInformationInquiryDetailRptModel( + timestamp = 1313L, command = 13, result = 1, firmVersion = "1.2.3", bootDateTime = "2026-07-16T00:00", modelName = "CLV-1" + ).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(1313L) + assertThat(out.command).isEqualTo(13) + assertThat(out.result).isEqualTo(1) + assertThat(out.firmVersion).isEqualTo("1.2.3") + assertThat(out.bootDateTime).isEqualTo("2026-07-16T00:00") + assertThat(out.modelName).isEqualTo("CLV-1") + } + + @Test + fun `ProtocolAppStatusRspModel maps status`() { + val out = ProtocolAppStatusRspModel(timestamp = 1414L, command = 14, status = 2).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(1414L) + assertThat(out.command).isEqualTo(14) + assertThat(out.status).isEqualTo(2) + } + + @Test + fun `ProtocolInfusionStatusInquiryRptModel maps all fields including renamed running and set minutes`() { + val out = ProtocolInfusionStatusInquiryRptModel( + timestamp = 1515L, + command = 15, + subId = 3, + patchRunningTime = 720, + insulinRemains = 120.5, + infusedTotalBasalAmount = 30.25, + infusedTotalBolusAmount = 12.75, + pumpState = 4, + mode = 5, + infusedSetMin = 60, + currentInfusedProgramVolume = 2.5, + realInfusedTime = 55 + ).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(1515L) + assertThat(out.command).isEqualTo(15) + assertThat(out.subId).isEqualTo(3) + assertThat(out.runningMinutes).isEqualTo(720) + assertThat(out.remains).isEqualTo(120.5) + assertThat(out.infusedTotalBasalAmount).isEqualTo(30.25) + assertThat(out.infusedTotalBolusAmount).isEqualTo(12.75) + assertThat(out.pumpState).isEqualTo(4) + assertThat(out.mode).isEqualTo(5) + assertThat(out.infusedSetMinutes).isEqualTo(60) + assertThat(out.currentInfusedProgramVolume).isEqualTo(2.5) + assertThat(out.realInfusedTime).isEqualTo(55) + } + + @Test + fun `ProtocolPumpStopRspModel maps to StopPumpResponse`() { + val out = ProtocolPumpStopRspModel(timestamp = 1616L, command = 16, result = 8).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(1616L) + assertThat(out.command).isEqualTo(16) + assertThat(out.result).isEqualTo(8) + } + + @Test + fun `ProtocolPumpStopRptModel maps subId to causeId and volumes and temperature`() { + val out = ProtocolPumpStopRptModel( + timestamp = 1717L, + command = 17, + result = 0, + cause = 99, + mode = 6, + subId = 42, + completedBolusInfusionVolume = 3.5, + unInfusedExtendBolusVolume = 1.25, + temperature = 37 + ).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(1717L) + assertThat(out.command).isEqualTo(17) + assertThat(out.result).isEqualTo(0) + assertThat(out.mode).isEqualTo(6) + assertThat(out.causeId).isEqualTo(42) + assertThat(out.infusedBolusAmount).isEqualTo(3.5) + assertThat(out.unInfusedExtendBolusAmount).isEqualTo(1.25) + assertThat(out.temperature).isEqualTo(37) + } + + @Test + fun `ProtocolPumpResumeRspModel maps subId to causeId`() { + val out = ProtocolPumpResumeRspModel(timestamp = 1818L, command = 18, result = 1, mode = 7, subId = 43).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(1818L) + assertThat(out.command).isEqualTo(18) + assertThat(out.result).isEqualTo(1) + assertThat(out.mode).isEqualTo(7) + assertThat(out.causeId).isEqualTo(43) + } + + @Test + fun `ProtocolPatchInitRspModel maps mode`() { + val out = ProtocolPatchInitRspModel(timestamp = 1919L, command = 19, mode = 8).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(1919L) + assertThat(out.command).isEqualTo(19) + assertThat(out.mode).isEqualTo(8) + } + + @Test + fun `ProtocolPatchDiscardRspModel maps to SetDiscardResponse`() { + val out = ProtocolPatchDiscardRspModel(timestamp = 2020L, command = 20, result = 9).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(2020L) + assertThat(out.command).isEqualTo(20) + assertThat(out.result).isEqualTo(9) + } + + @Test + fun `ProtocolPatchBuzzInspectionRspModel maps to CheckBuzzResponse`() { + val out = ProtocolPatchBuzzInspectionRspModel(timestamp = 2121L, command = 21, result = 10).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(2121L) + assertThat(out.command).isEqualTo(21) + assertThat(out.result).isEqualTo(10) + } + + @Test + fun `ProtocolPatchOperationDataRspModel maps useMin to useMinutes and rest`() { + val out = ProtocolPatchOperationDataRspModel( + timestamp = 2222L, command = 22, mode = 9, pulseCnt = 1000, totalNo = 5, count = 3, useMin = 480, remains = 88.5 + ).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(2222L) + assertThat(out.command).isEqualTo(22) + assertThat(out.mode).isEqualTo(9) + assertThat(out.pulseCnt).isEqualTo(1000) + assertThat(out.totalNo).isEqualTo(5) + assertThat(out.count).isEqualTo(3) + assertThat(out.useMinutes).isEqualTo(480) + assertThat(out.remains).isEqualTo(88.5) + } + + @Test + fun `ProtocolPatchAddressRspModel maps macAddress to address and checkSum`() { + val out = ProtocolPatchAddressRspModel(timestamp = 2323L, command = 23, macAddress = "AA:BB:CC:DD:EE:FF", checkSum = "1F").transformToDomainModel() + assertThat(out.timestamp).isEqualTo(2323L) + assertThat(out.command).isEqualTo(23) + assertThat(out.address).isEqualTo("AA:BB:CC:DD:EE:FF") + assertThat(out.checkSum).isEqualTo("1F") + } + + @Test + fun `ProtocolWarningMsgRptModel maps cause and value`() { + val out = ProtocolWarningMsgRptModel(timestamp = 2424L, command = 24, cause = 11, value = 22).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(2424L) + assertThat(out.command).isEqualTo(24) + assertThat(out.cause).isEqualTo(11) + assertThat(out.value).isEqualTo(22) + } + + @Test + fun `ProtocolAlertMsgRptModel maps cause and value`() { + val out = ProtocolAlertMsgRptModel(timestamp = 2525L, command = 25, cause = 12, value = 23).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(2525L) + assertThat(out.command).isEqualTo(25) + assertThat(out.cause).isEqualTo(12) + assertThat(out.value).isEqualTo(23) + } + + @Test + fun `ProtocolNoticeMsgRptModel maps cause and value`() { + val out = ProtocolNoticeMsgRptModel(timestamp = 2626L, command = 26, cause = 13, value = 24).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(2626L) + assertThat(out.command).isEqualTo(26) + assertThat(out.cause).isEqualTo(13) + assertThat(out.value).isEqualTo(24) + } + + @Test + fun `ProtocolMsgSolutionRspModel maps result subId cause`() { + val out = ProtocolMsgSolutionRspModel(timestamp = 2727L, command = 27, result = 0, subId = 14, cause = 25).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(2727L) + assertThat(out.command).isEqualTo(27) + assertThat(out.result).isEqualTo(0) + assertThat(out.subId).isEqualTo(14) + assertThat(out.cause).isEqualTo(25) + } + + @Test + fun `ProtocolBasalProgramSetRspModel maps to SetBasalProgramResponse`() { + val out = ProtocolBasalProgramSetRspModel(timestamp = 2828L, command = 28, result = 15).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(2828L) + assertThat(out.command).isEqualTo(28) + assertThat(out.result).isEqualTo(15) + } + + @Test + fun `ProtocolAdditionalBasalProgramSetRspModel maps to SetBasalProgramAdditionalResponse`() { + val out = ProtocolAdditionalBasalProgramSetRspModel(timestamp = 2929L, command = 29, result = 16).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(2929L) + assertThat(out.command).isEqualTo(29) + assertThat(out.result).isEqualTo(16) + } + + @Test + fun `ProtocolBasalInfusionChangeRspModel maps to UpdateBasalProgramResponse`() { + val out = ProtocolBasalInfusionChangeRspModel(timestamp = 3030L, command = 30, result = 17).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(3030L) + assertThat(out.command).isEqualTo(30) + assertThat(out.result).isEqualTo(17) + } + + @Test + fun `ProtocolAdditionalBasalInfusionChangeRspModel maps to UpdateBasalProgramAdditionalResponse`() { + val out = ProtocolAdditionalBasalInfusionChangeRspModel(timestamp = 3131L, command = 31, result = 18).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(3131L) + assertThat(out.command).isEqualTo(31) + assertThat(out.result).isEqualTo(18) + } + + @Test + fun `ProtocolTempBasalInfusionRspModel maps to StartTempBasalProgramResponse`() { + val out = ProtocolTempBasalInfusionRspModel(timestamp = 3232L, command = 32, result = 19).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(3232L) + assertThat(out.command).isEqualTo(32) + assertThat(out.result).isEqualTo(19) + } + + @Test + fun `ProtocolTempBasalInfusionCancelRspModel maps to CancelTempBasalProgramResponse`() { + val out = ProtocolTempBasalInfusionCancelRspModel(timestamp = 3333L, command = 33, result = 20).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(3333L) + assertThat(out.command).isEqualTo(33) + assertThat(out.result).isEqualTo(20) + } + + @Test + fun `ProtocolBasalInfusionStartRspModel maps timestamp and command only`() { + val out = ProtocolBasalInfusionStartRspModel(timestamp = 3434L, command = 34).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(3434L) + assertThat(out.command).isEqualTo(34) + } + + @Test + fun `ProtocolBasalInfusionResumeRspModel maps segment speed period remains`() { + val out = ProtocolBasalInfusionResumeRspModel( + timestamp = 3535L, command = 35, segmentNo = 4, infusionSpeed = 1.75, infusionPeriod = 30, insulinRemains = 99.9 + ).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(3535L) + assertThat(out.command).isEqualTo(35) + assertThat(out.segmentNo).isEqualTo(4) + assertThat(out.infusionSpeed).isEqualTo(1.75) + assertThat(out.infusionPeriod).isEqualTo(30) + assertThat(out.insulinRemains).isEqualTo(99.9) + } + + @Test + fun `ProtocolImmeBolusInfusionRspModel maps remains to remain and actionId expectedTime`() { + val out = ProtocolImmeBolusInfusionRspModel( + timestamp = 3636L, command = 36, actionId = 7, result = 0, expectedTime = 120, remains = 55.5 + ).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(3636L) + assertThat(out.command).isEqualTo(36) + assertThat(out.result).isEqualTo(0) + assertThat(out.actionId).isEqualTo(7) + assertThat(out.expectedTime).isEqualTo(120) + assertThat(out.remain).isEqualTo(55.5) + } + + @Test + fun `ProtocolBolusInfusionCancelRspModel maps insulinRemains to remains and infusedAmount`() { + val out = ProtocolBolusInfusionCancelRspModel( + timestamp = 3737L, command = 37, result = 1, insulinRemains = 44.4, infusedAmount = 5.5 + ).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(3737L) + assertThat(out.command).isEqualTo(37) + assertThat(out.result).isEqualTo(1) + assertThat(out.remains).isEqualTo(44.4) + assertThat(out.infusedAmount).isEqualTo(5.5) + } + + @Test + fun `ProtocolExtendBolusInfusionRspModel maps result and expectedTime`() { + val out = ProtocolExtendBolusInfusionRspModel(timestamp = 3838L, command = 38, result = 0, expectedTime = 240).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(3838L) + assertThat(out.command).isEqualTo(38) + assertThat(out.result).isEqualTo(0) + assertThat(out.expectedTime).isEqualTo(240) + } + + @Test + fun `ProtocolExtendBolusInfusionCancelRspModel maps result and infusedAmount`() { + val out = ProtocolExtendBolusInfusionCancelRspModel(timestamp = 3939L, command = 39, result = 2, infusedAmount = 6.25).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(3939L) + assertThat(out.command).isEqualTo(39) + assertThat(out.result).isEqualTo(2) + assertThat(out.infusedAmount).isEqualTo(6.25) + } + + @Test + fun `ProtocolExtendBolusDelayRptModel maps delayedAmount and expectedTime`() { + val out = ProtocolExtendBolusDelayRptModel(timestamp = 4040L, command = 40, delayedAmount = 7.5, expectedTime = 360).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(4040L) + assertThat(out.command).isEqualTo(40) + assertThat(out.delayedAmount).isEqualTo(7.5) + assertThat(out.expectedTime).isEqualTo(360) + } + + @Test + fun `ProtocolPatchRecoveryRptModel maps timestamp and command only`() { + val out = ProtocolPatchRecoveryRptModel(timestamp = 4141L, command = 41).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(4141L) + assertThat(out.command).isEqualTo(41) + } + + @Test + fun `ProtocolAppAuthKeyAckRspModel maps value`() { + val out = ProtocolAppAuthKeyAckRspModel(timestamp = 4242L, command = 42, value = 12345).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(4242L) + assertThat(out.command).isEqualTo(42) + assertThat(out.value).isEqualTo(12345) + } + + @Test + fun `ProtocolAppAuthAckRspModel maps result`() { + val out = ProtocolAppAuthAckRspModel(timestamp = 4343L, command = 43, result = 21).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(4343L) + assertThat(out.command).isEqualTo(43) + assertThat(out.result).isEqualTo(21) + } + + @Test + fun `negative and zero edge values pass through unchanged`() { + val out = ProtocolInfusionStatusInquiryRptModel( + timestamp = 0L, + command = 0, + subId = 0, + patchRunningTime = 0, + insulinRemains = 0.0, + infusedTotalBasalAmount = 0.0, + infusedTotalBolusAmount = 0.0, + pumpState = 0, + mode = 0, + infusedSetMin = 0, + currentInfusedProgramVolume = 0.0, + realInfusedTime = 0 + ).transformToDomainModel() + assertThat(out.timestamp).isEqualTo(0L) + assertThat(out.remains).isEqualTo(0.0) + assertThat(out.runningMinutes).isEqualTo(0) + } + + @Test + fun `empty string fields pass through unchanged`() { + val out = ProtocolPatchInformationInquiryDetailRptModel( + timestamp = 1L, command = 1, result = 0, firmVersion = "", bootDateTime = "", modelName = "" + ).transformToDomainModel() + assertThat(out.firmVersion).isEqualTo("") + assertThat(out.bootDateTime).isEqualTo("") + assertThat(out.modelName).isEqualTo("") + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoInfusionInfoMapperTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoInfusionInfoMapperTest.kt new file mode 100644 index 000000000000..2473cec71c41 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoInfusionInfoMapperTest.kt @@ -0,0 +1,412 @@ +package app.aaps.pump.carelevo.data.mapper + +import app.aaps.pump.carelevo.data.model.entities.CarelevoBasalInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoBasalSegmentInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoExtendBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoImmeBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoTempBasalInfusionInfoEntity +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoBasalSegmentInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoExtendBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoImmeBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoTempBasalInfusionInfoDomainModel +import com.google.common.truth.Truth.assertThat +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import org.junit.jupiter.api.Test + +/** + * Pure-logic unit tests for the entity <-> domain mappers in `CarelevoInfusionInfoMapper.kt`. + * + * Timestamp handling: entity strings are canonical ISO-8601 in UTC so that a + * `String -> DateTime.parse -> DateTime.toString -> String` round trip is stable and the produced + * DateTime chronology matches a UTC-built DateTime (offset 0 resolves to DateTimeZone.UTC). + */ +internal class CarelevoInfusionInfoMapperTest { + + private val createdStr = "2026-07-16T12:30:45.123Z" + private val updatedStr = "2026-07-16T13:31:46.456Z" + private val createdDt = DateTime(2026, 7, 16, 12, 30, 45, 123, DateTimeZone.UTC) + private val updatedDt = DateTime(2026, 7, 16, 13, 31, 46, 456, DateTimeZone.UTC) + + // region BasalSegment + + @Test + fun `basal segment entity to domain maps every field`() { + val entity = CarelevoBasalSegmentInfusionInfoEntity( + createdAt = createdStr, updatedAt = updatedStr, startTime = 60, endTime = 120, speed = 1.25 + ) + val domain = entity.transformToCarelevoBasalSegmentInfusionInfoDomainModel() + assertThat(domain.createdAt).isEqualTo(DateTime.parse(createdStr)) + assertThat(domain.updatedAt).isEqualTo(DateTime.parse(updatedStr)) + assertThat(domain.startTime).isEqualTo(60) + assertThat(domain.endTime).isEqualTo(120) + assertThat(domain.speed).isEqualTo(1.25) + } + + @Test + fun `basal segment domain to entity maps every field and serializes timestamps`() { + val domain = CarelevoBasalSegmentInfusionInfoDomainModel( + createdAt = createdDt, updatedAt = updatedDt, startTime = 0, endTime = 30, speed = 0.5 + ) + val entity = domain.transformToCarelevoBasalSegmentInfusionInfoEntity() + assertThat(entity.createdAt).isEqualTo(createdDt.toString()) + assertThat(entity.updatedAt).isEqualTo(updatedDt.toString()) + assertThat(entity.startTime).isEqualTo(0) + assertThat(entity.endTime).isEqualTo(30) + assertThat(entity.speed).isEqualTo(0.5) + } + + @Test + fun `basal segment entity round trips through domain`() { + val entity = CarelevoBasalSegmentInfusionInfoEntity(createdStr, updatedStr, 15, 45, 2.0) + val back = entity.transformToCarelevoBasalSegmentInfusionInfoDomainModel() + .transformToCarelevoBasalSegmentInfusionInfoEntity() + assertThat(back).isEqualTo(entity) + } + + // endregion + + // region Basal + + @Test + fun `basal entity to domain maps scalar fields and segment list`() { + val entity = CarelevoBasalInfusionInfoEntity( + infusionId = "inf-1", address = "AA:BB", mode = 1, + createdAt = createdStr, updatedAt = updatedStr, + segments = listOf( + CarelevoBasalSegmentInfusionInfoEntity(createdStr, updatedStr, 0, 60, 1.0), + CarelevoBasalSegmentInfusionInfoEntity(createdStr, updatedStr, 60, 120, 2.0) + ), + isStop = false + ) + val domain = entity.transformToCarelevoBasalInfusionInfoDomainModel() + assertThat(domain.infusionId).isEqualTo("inf-1") + assertThat(domain.address).isEqualTo("AA:BB") + assertThat(domain.mode).isEqualTo(1) + assertThat(domain.createdAt).isEqualTo(DateTime.parse(createdStr)) + assertThat(domain.updatedAt).isEqualTo(DateTime.parse(updatedStr)) + assertThat(domain.isStop).isFalse() + assertThat(domain.segments).hasSize(2) + assertThat(domain.segments[0].speed).isEqualTo(1.0) + assertThat(domain.segments[1].speed).isEqualTo(2.0) + } + + @Test + fun `basal entity to domain with empty segment list`() { + val entity = CarelevoBasalInfusionInfoEntity( + "inf-2", "CC:DD", 0, createdStr, updatedStr, emptyList(), isStop = true + ) + val domain = entity.transformToCarelevoBasalInfusionInfoDomainModel() + assertThat(domain.segments).isEmpty() + assertThat(domain.isStop).isTrue() + } + + @Test + fun `basal domain to entity maps scalar fields and segment list`() { + val domain = CarelevoBasalInfusionInfoDomainModel( + infusionId = "inf-3", address = "EE:FF", mode = 1, + createdAt = createdDt, updatedAt = updatedDt, + segments = listOf(CarelevoBasalSegmentInfusionInfoDomainModel(createdDt, updatedDt, 0, 90, 3.0)), + isStop = false + ) + val entity = domain.transformToCarelevoBasalInfusionInfoEntity() + assertThat(entity.infusionId).isEqualTo("inf-3") + assertThat(entity.address).isEqualTo("EE:FF") + assertThat(entity.mode).isEqualTo(1) + assertThat(entity.createdAt).isEqualTo(createdDt.toString()) + assertThat(entity.updatedAt).isEqualTo(updatedDt.toString()) + assertThat(entity.isStop).isFalse() + assertThat(entity.segments).hasSize(1) + assertThat(entity.segments[0].speed).isEqualTo(3.0) + } + + @Test + fun `basal entity round trips through domain`() { + val entity = CarelevoBasalInfusionInfoEntity( + "inf-4", "11:22", 1, createdStr, updatedStr, + listOf(CarelevoBasalSegmentInfusionInfoEntity(createdStr, updatedStr, 0, 60, 1.5)), + isStop = true + ) + val back = entity.transformToCarelevoBasalInfusionInfoDomainModel() + .transformToCarelevoBasalInfusionInfoEntity() + assertThat(back).isEqualTo(entity) + } + + // endregion + + // region TempBasal + + @Test + fun `temp basal entity to domain with all optionals present`() { + val entity = CarelevoTempBasalInfusionInfoEntity( + infusionId = "t-1", address = "AA", mode = 2, + createdAt = createdStr, updatedAt = updatedStr, + percent = 150, speed = 2.5, infusionDurationMin = 30 + ) + val domain = entity.transformToCarelevoTempBasalInfusionInfoDomainModel() + assertThat(domain.infusionId).isEqualTo("t-1") + assertThat(domain.address).isEqualTo("AA") + assertThat(domain.mode).isEqualTo(2) + assertThat(domain.percent).isEqualTo(150) + assertThat(domain.speed).isEqualTo(2.5) + assertThat(domain.infusionDurationMin).isEqualTo(30) + } + + @Test + fun `temp basal entity to domain with all optionals null`() { + val entity = CarelevoTempBasalInfusionInfoEntity( + "t-2", "BB", 2, createdStr, updatedStr, percent = null, speed = null, infusionDurationMin = null + ) + val domain = entity.transformToCarelevoTempBasalInfusionInfoDomainModel() + assertThat(domain.percent).isNull() + assertThat(domain.speed).isNull() + assertThat(domain.infusionDurationMin).isNull() + } + + @Test + fun `temp basal domain to entity with all optionals present`() { + val domain = CarelevoTempBasalInfusionInfoDomainModel( + infusionId = "t-3", address = "CC", mode = 2, + createdAt = createdDt, updatedAt = updatedDt, + percent = 80, speed = 1.1, infusionDurationMin = 60 + ) + val entity = domain.transformToCarelevoTempBasalInfusionInfoEntity() + assertThat(entity.infusionId).isEqualTo("t-3") + assertThat(entity.createdAt).isEqualTo(createdDt.toString()) + assertThat(entity.updatedAt).isEqualTo(updatedDt.toString()) + assertThat(entity.percent).isEqualTo(80) + assertThat(entity.speed).isEqualTo(1.1) + assertThat(entity.infusionDurationMin).isEqualTo(60) + } + + @Test + fun `temp basal domain to entity with all optionals null`() { + val domain = CarelevoTempBasalInfusionInfoDomainModel( + "t-4", "DD", 2, createdDt, updatedDt, percent = null, speed = null, infusionDurationMin = null + ) + val entity = domain.transformToCarelevoTempBasalInfusionInfoEntity() + assertThat(entity.percent).isNull() + assertThat(entity.speed).isNull() + assertThat(entity.infusionDurationMin).isNull() + } + + @Test + fun `temp basal entity round trips through domain`() { + val entity = CarelevoTempBasalInfusionInfoEntity("t-5", "EE", 2, createdStr, updatedStr, 200, 3.0, 45) + val back = entity.transformToCarelevoTempBasalInfusionInfoDomainModel() + .transformToCarelevoTempBasalInfusionInfoEntity() + assertThat(back).isEqualTo(entity) + } + + // endregion + + // region ImmeBolus + + @Test + fun `imme bolus entity to domain with optionals present`() { + val entity = CarelevoImmeBolusInfusionInfoEntity( + infusionId = "i-1", address = "AA", mode = 3, + createdAt = createdStr, updatedAt = updatedStr, + volume = 4.5, infusionDurationSeconds = 90 + ) + val domain = entity.transformToCarelevoImmeBolusInfusionInfoDomainModel() + assertThat(domain.infusionId).isEqualTo("i-1") + assertThat(domain.mode).isEqualTo(3) + assertThat(domain.volume).isEqualTo(4.5) + assertThat(domain.infusionDurationSeconds).isEqualTo(90) + } + + @Test + fun `imme bolus entity to domain with optionals null`() { + val entity = CarelevoImmeBolusInfusionInfoEntity("i-2", "BB", 3, createdStr, updatedStr, null, null) + val domain = entity.transformToCarelevoImmeBolusInfusionInfoDomainModel() + assertThat(domain.volume).isNull() + assertThat(domain.infusionDurationSeconds).isNull() + } + + @Test + fun `imme bolus domain to entity with optionals present`() { + val domain = CarelevoImmeBolusInfusionInfoDomainModel( + "i-3", "CC", 3, createdDt, updatedDt, volume = 2.0, infusionDurationSeconds = 30 + ) + val entity = domain.transformToCarelevoImmeBolusInfusionInfoEntity() + assertThat(entity.createdAt).isEqualTo(createdDt.toString()) + assertThat(entity.volume).isEqualTo(2.0) + assertThat(entity.infusionDurationSeconds).isEqualTo(30) + } + + @Test + fun `imme bolus domain to entity with optionals null`() { + val domain = CarelevoImmeBolusInfusionInfoDomainModel("i-4", "DD", 3, createdDt, updatedDt, null, null) + val entity = domain.transformToCarelevoImmeBolusInfusionInfoEntity() + assertThat(entity.volume).isNull() + assertThat(entity.infusionDurationSeconds).isNull() + } + + @Test + fun `imme bolus entity round trips through domain`() { + val entity = CarelevoImmeBolusInfusionInfoEntity("i-5", "EE", 3, createdStr, updatedStr, 5.0, 120) + val back = entity.transformToCarelevoImmeBolusInfusionInfoDomainModel() + .transformToCarelevoImmeBolusInfusionInfoEntity() + assertThat(back).isEqualTo(entity) + } + + // endregion + + // region ExtendBolus + + @Test + fun `extend bolus entity to domain with optionals present`() { + val entity = CarelevoExtendBolusInfusionInfoEntity( + infusionId = "e-1", address = "AA", mode = 5, + createdAt = createdStr, updatedAt = updatedStr, + volume = 6.0, speed = 1.0, infusionDurationMin = 120 + ) + val domain = entity.transformToCarelevoExtendBolusInfusionInfoDomainModel() + assertThat(domain.mode).isEqualTo(5) + assertThat(domain.volume).isEqualTo(6.0) + assertThat(domain.speed).isEqualTo(1.0) + assertThat(domain.infusionDurationMin).isEqualTo(120) + } + + @Test + fun `extend bolus entity to domain with optionals null`() { + val entity = CarelevoExtendBolusInfusionInfoEntity("e-2", "BB", 5, createdStr, updatedStr, null, null, null) + val domain = entity.transformToCarelevoExtendBolusInfusionInfoDomainModel() + assertThat(domain.volume).isNull() + assertThat(domain.speed).isNull() + assertThat(domain.infusionDurationMin).isNull() + } + + @Test + fun `extend bolus domain to entity with optionals present`() { + val domain = CarelevoExtendBolusInfusionInfoDomainModel( + "e-3", "CC", 5, createdDt, updatedDt, volume = 3.5, speed = 0.75, infusionDurationMin = 90 + ) + val entity = domain.transformToCarelevoExtendBolusInfusionInfoEntity() + assertThat(entity.updatedAt).isEqualTo(updatedDt.toString()) + assertThat(entity.volume).isEqualTo(3.5) + assertThat(entity.speed).isEqualTo(0.75) + assertThat(entity.infusionDurationMin).isEqualTo(90) + } + + @Test + fun `extend bolus domain to entity with optionals null`() { + val domain = CarelevoExtendBolusInfusionInfoDomainModel("e-4", "DD", 5, createdDt, updatedDt, null, null, null) + val entity = domain.transformToCarelevoExtendBolusInfusionInfoEntity() + assertThat(entity.volume).isNull() + assertThat(entity.speed).isNull() + assertThat(entity.infusionDurationMin).isNull() + } + + @Test + fun `extend bolus entity round trips through domain`() { + val entity = CarelevoExtendBolusInfusionInfoEntity("e-5", "EE", 5, createdStr, updatedStr, 7.0, 1.25, 180) + val back = entity.transformToCarelevoExtendBolusInfusionInfoDomainModel() + .transformToCarelevoExtendBolusInfusionInfoEntity() + assertThat(back).isEqualTo(entity) + } + + // endregion + + // region Container (CarelevoInfusionInfo) + + @Test + fun `infusion info entity to domain with all sub-infusions null`() { + val domain = CarelevoInfusionInfoEntity().transformToCarelevoInfusionInfoDomainModel() + assertThat(domain.basalInfusionInfo).isNull() + assertThat(domain.tempBasalInfusionInfo).isNull() + assertThat(domain.immeBolusInfusionInfo).isNull() + assertThat(domain.extendBolusInfusionInfo).isNull() + } + + @Test + fun `infusion info entity to domain with all sub-infusions present`() { + val entity = CarelevoInfusionInfoEntity( + basalInfusionInfo = CarelevoBasalInfusionInfoEntity("b", "A", 1, createdStr, updatedStr, emptyList(), false), + tempBasalInfusionInfo = CarelevoTempBasalInfusionInfoEntity("t", "A", 2, createdStr, updatedStr, 100, 1.0, 30), + immeBolusInfusionInfo = CarelevoImmeBolusInfusionInfoEntity("i", "A", 3, createdStr, updatedStr, 2.0, 60), + extendBolusInfusionInfo = CarelevoExtendBolusInfusionInfoEntity("e", "A", 5, createdStr, updatedStr, 3.0, 0.5, 90) + ) + val domain = entity.transformToCarelevoInfusionInfoDomainModel() + assertThat(domain.basalInfusionInfo?.infusionId).isEqualTo("b") + assertThat(domain.tempBasalInfusionInfo?.infusionId).isEqualTo("t") + assertThat(domain.immeBolusInfusionInfo?.infusionId).isEqualTo("i") + assertThat(domain.extendBolusInfusionInfo?.infusionId).isEqualTo("e") + } + + @Test + fun `infusion info entity to domain with only temp basal present`() { + val entity = CarelevoInfusionInfoEntity( + tempBasalInfusionInfo = CarelevoTempBasalInfusionInfoEntity("t", "A", 2, createdStr, updatedStr) + ) + val domain = entity.transformToCarelevoInfusionInfoDomainModel() + assertThat(domain.basalInfusionInfo).isNull() + assertThat(domain.tempBasalInfusionInfo).isNotNull() + assertThat(domain.immeBolusInfusionInfo).isNull() + assertThat(domain.extendBolusInfusionInfo).isNull() + } + + @Test + fun `infusion info domain to entity with all sub-infusions null`() { + val entity = CarelevoInfusionInfoDomainModel().transformToCarelevoInfusionInfoEntity() + assertThat(entity.basalInfusionInfo).isNull() + assertThat(entity.tempBasalInfusionInfo).isNull() + assertThat(entity.immeBolusInfusionInfo).isNull() + assertThat(entity.extendBolusInfusionInfo).isNull() + } + + @Test + fun `infusion info domain to entity with all sub-infusions present`() { + val domain = CarelevoInfusionInfoDomainModel( + basalInfusionInfo = CarelevoBasalInfusionInfoDomainModel("b", "A", 1, createdDt, updatedDt, emptyList(), false), + tempBasalInfusionInfo = CarelevoTempBasalInfusionInfoDomainModel("t", "A", 2, createdDt, updatedDt, 100, 1.0, 30), + immeBolusInfusionInfo = CarelevoImmeBolusInfusionInfoDomainModel("i", "A", 3, createdDt, updatedDt, 2.0, 60), + extendBolusInfusionInfo = CarelevoExtendBolusInfusionInfoDomainModel("e", "A", 5, createdDt, updatedDt, 3.0, 0.5, 90) + ) + val entity = domain.transformToCarelevoInfusionInfoEntity() + assertThat(entity.basalInfusionInfo?.infusionId).isEqualTo("b") + assertThat(entity.tempBasalInfusionInfo?.infusionId).isEqualTo("t") + assertThat(entity.immeBolusInfusionInfo?.infusionId).isEqualTo("i") + assertThat(entity.extendBolusInfusionInfo?.infusionId).isEqualTo("e") + } + + @Test + fun `infusion info domain to entity with only extend bolus present`() { + val domain = CarelevoInfusionInfoDomainModel( + extendBolusInfusionInfo = CarelevoExtendBolusInfusionInfoDomainModel("e", "A", 5, createdDt, updatedDt) + ) + val entity = domain.transformToCarelevoInfusionInfoEntity() + assertThat(entity.basalInfusionInfo).isNull() + assertThat(entity.tempBasalInfusionInfo).isNull() + assertThat(entity.immeBolusInfusionInfo).isNull() + assertThat(entity.extendBolusInfusionInfo).isNotNull() + } + + @Test + fun `infusion info entity round trips through domain when fully populated`() { + val entity = CarelevoInfusionInfoEntity( + basalInfusionInfo = CarelevoBasalInfusionInfoEntity( + "b", "A", 1, createdStr, updatedStr, + listOf(CarelevoBasalSegmentInfusionInfoEntity(createdStr, updatedStr, 0, 60, 1.0)), false + ), + tempBasalInfusionInfo = CarelevoTempBasalInfusionInfoEntity("t", "A", 2, createdStr, updatedStr, 100, 1.0, 30), + immeBolusInfusionInfo = CarelevoImmeBolusInfusionInfoEntity("i", "A", 3, createdStr, updatedStr, 2.0, 60), + extendBolusInfusionInfo = CarelevoExtendBolusInfusionInfoEntity("e", "A", 5, createdStr, updatedStr, 3.0, 0.5, 90) + ) + val back = entity.transformToCarelevoInfusionInfoDomainModel().transformToCarelevoInfusionInfoEntity() + assertThat(back).isEqualTo(entity) + } + + @Test + fun `empty infusion info entity round trips to empty`() { + val entity = CarelevoInfusionInfoEntity() + val back = entity.transformToCarelevoInfusionInfoDomainModel().transformToCarelevoInfusionInfoEntity() + assertThat(back).isEqualTo(entity) + } + + // endregion +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoPatchInfoMapperTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoPatchInfoMapperTest.kt new file mode 100644 index 000000000000..b817e83c2c1b --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoPatchInfoMapperTest.kt @@ -0,0 +1,358 @@ +package app.aaps.pump.carelevo.data.mapper + +import app.aaps.pump.carelevo.data.model.entities.CarelevoPatchInfoEntity +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import com.google.common.truth.Truth.assertThat +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class CarelevoPatchInfoMapperTest { + + // Canonical ISO-8601 strings (Joda DateTime.toString() form: yyyy-MM-ddTHH:mm:ss.SSSZZ). + // Using canonical form makes the entity round-trip (parse -> toString) exact. + private val createdAtStr = "2024-01-15T10:30:00.000+02:00" + private val updatedAtStr = "2024-02-20T08:15:30.500+02:00" + + private fun fullEntity() = CarelevoPatchInfoEntity( + address = "AA:BB:CC:DD:EE:FF", + createdAt = createdAtStr, + updatedAt = updatedAtStr, + manufactureNumber = "MFG-123", + firmwareVersion = "1.2.3", + bootDateTime = "2024-01-15T09:00:00", + bootDateTimeUtcMillis = 1705309200000L, + modelName = "CareLevo-X", + insulinAmount = 300, + insulinRemain = 145.5, + thresholdInsulinRemain = 20, + thresholdExpiry = 116, + thresholdMaxBasalSpeed = 2.5, + thresholdMaxBolusDose = 15.0, + checkSafety = true, + checkNeedle = false, + needleFailedCount = 3, + isConnected = true, + needDiscard = false, + isDiscard = true, + isExtended = false, + isValid = true, + isStopped = false, + stopMinutes = 42, + stopMode = 2, + isForceStopped = true, + runningMinutes = 1234, + infusedTotalBasalAmount = 12.34, + infusedTotalBolusAmount = 56.78, + pumpState = 5, + mode = 1, + bolusActionSeq = 7 + ) + + private fun fullDomain() = CarelevoPatchInfoDomainModel( + address = "AA:BB:CC:DD:EE:FF", + createdAt = DateTime.parse(createdAtStr), + updatedAt = DateTime.parse(updatedAtStr), + manufactureNumber = "MFG-123", + firmwareVersion = "1.2.3", + bootDateTime = "2024-01-15T09:00:00", + bootDateTimeUtcMillis = 1705309200000L, + modelName = "CareLevo-X", + insulinAmount = 300, + insulinRemain = 145.5, + thresholdInsulinRemain = 20, + thresholdExpiry = 116, + thresholdMaxBasalSpeed = 2.5, + thresholdMaxBolusDose = 15.0, + checkSafety = true, + checkNeedle = false, + needleFailedCount = 3, + isConnected = true, + needDiscard = false, + isDiscard = true, + isExtended = false, + isValid = true, + isStopped = false, + stopMinutes = 42, + stopMode = 2, + isForceStopped = true, + runningMinutes = 1234, + infusedTotalBasalAmount = 12.34, + infusedTotalBolusAmount = 56.78, + pumpState = 5, + mode = 1, + bolusActionSeq = 7 + ) + + // ---------- Entity -> Domain ---------- + + @Test + fun `entity to domain maps every scalar field`() { + val d = fullEntity().transformToCarelevoPatchInfoDomainModel() + assertThat(d.address).isEqualTo("AA:BB:CC:DD:EE:FF") + assertThat(d.manufactureNumber).isEqualTo("MFG-123") + assertThat(d.firmwareVersion).isEqualTo("1.2.3") + assertThat(d.bootDateTime).isEqualTo("2024-01-15T09:00:00") + assertThat(d.bootDateTimeUtcMillis).isEqualTo(1705309200000L) + assertThat(d.modelName).isEqualTo("CareLevo-X") + assertThat(d.insulinAmount).isEqualTo(300) + assertThat(d.insulinRemain).isEqualTo(145.5) + assertThat(d.thresholdInsulinRemain).isEqualTo(20) + assertThat(d.thresholdExpiry).isEqualTo(116) + assertThat(d.thresholdMaxBasalSpeed).isEqualTo(2.5) + assertThat(d.thresholdMaxBolusDose).isEqualTo(15.0) + assertThat(d.checkSafety).isTrue() + assertThat(d.checkNeedle).isFalse() + assertThat(d.needleFailedCount).isEqualTo(3) + assertThat(d.isConnected).isTrue() + assertThat(d.needDiscard).isFalse() + assertThat(d.isDiscard).isTrue() + assertThat(d.isExtended).isFalse() + assertThat(d.isValid).isTrue() + assertThat(d.isStopped).isFalse() + assertThat(d.stopMinutes).isEqualTo(42) + assertThat(d.stopMode).isEqualTo(2) + assertThat(d.isForceStopped).isTrue() + assertThat(d.runningMinutes).isEqualTo(1234) + assertThat(d.infusedTotalBasalAmount).isEqualTo(12.34) + assertThat(d.infusedTotalBolusAmount).isEqualTo(56.78) + assertThat(d.pumpState).isEqualTo(5) + assertThat(d.mode).isEqualTo(1) + assertThat(d.bolusActionSeq).isEqualTo(7) + } + + @Test + fun `entity to domain parses createdAt and updatedAt strings`() { + val d = fullEntity().transformToCarelevoPatchInfoDomainModel() + assertThat(d.createdAt).isEqualTo(DateTime.parse(createdAtStr)) + assertThat(d.updatedAt).isEqualTo(DateTime.parse(updatedAtStr)) + // Instant preserved regardless of chronology. + assertThat(d.createdAt.isEqual(DateTime.parse(createdAtStr))).isTrue() + } + + @Test + fun `entity to domain preserves all null optionals`() { + val minimal = CarelevoPatchInfoEntity( + address = "addr", + createdAt = createdAtStr, + updatedAt = updatedAtStr + ) + val d = minimal.transformToCarelevoPatchInfoDomainModel() + assertThat(d.address).isEqualTo("addr") + assertThat(d.manufactureNumber).isNull() + assertThat(d.firmwareVersion).isNull() + assertThat(d.bootDateTime).isNull() + assertThat(d.bootDateTimeUtcMillis).isNull() + assertThat(d.modelName).isNull() + assertThat(d.insulinAmount).isNull() + assertThat(d.insulinRemain).isNull() + assertThat(d.thresholdInsulinRemain).isNull() + assertThat(d.thresholdExpiry).isNull() + assertThat(d.thresholdMaxBasalSpeed).isNull() + assertThat(d.thresholdMaxBolusDose).isNull() + assertThat(d.checkSafety).isNull() + assertThat(d.checkNeedle).isNull() + assertThat(d.needleFailedCount).isNull() + assertThat(d.isConnected).isNull() + assertThat(d.needDiscard).isNull() + assertThat(d.isDiscard).isNull() + assertThat(d.isExtended).isNull() + assertThat(d.isValid).isNull() + assertThat(d.isStopped).isNull() + assertThat(d.stopMinutes).isNull() + assertThat(d.stopMode).isNull() + assertThat(d.isForceStopped).isNull() + assertThat(d.runningMinutes).isNull() + assertThat(d.infusedTotalBasalAmount).isNull() + assertThat(d.infusedTotalBolusAmount).isNull() + assertThat(d.pumpState).isNull() + assertThat(d.mode).isNull() + assertThat(d.bolusActionSeq).isNull() + } + + @Test + fun `entity to domain throws on unparseable createdAt`() { + val bad = CarelevoPatchInfoEntity(address = "addr", createdAt = "not-a-date", updatedAt = updatedAtStr) + assertFailsWith { bad.transformToCarelevoPatchInfoDomainModel() } + } + + @Test + fun `entity to domain throws on unparseable updatedAt`() { + val bad = CarelevoPatchInfoEntity(address = "addr", createdAt = createdAtStr, updatedAt = "garbage") + assertFailsWith { bad.transformToCarelevoPatchInfoDomainModel() } + } + + // ---------- Domain -> Entity ---------- + + @Test + fun `domain to entity maps every scalar field`() { + val e = fullDomain().transformToCarelevoPatchInfoEntity() + assertThat(e.address).isEqualTo("AA:BB:CC:DD:EE:FF") + assertThat(e.manufactureNumber).isEqualTo("MFG-123") + assertThat(e.firmwareVersion).isEqualTo("1.2.3") + assertThat(e.bootDateTime).isEqualTo("2024-01-15T09:00:00") + assertThat(e.bootDateTimeUtcMillis).isEqualTo(1705309200000L) + assertThat(e.modelName).isEqualTo("CareLevo-X") + assertThat(e.insulinAmount).isEqualTo(300) + assertThat(e.insulinRemain).isEqualTo(145.5) + assertThat(e.thresholdInsulinRemain).isEqualTo(20) + assertThat(e.thresholdExpiry).isEqualTo(116) + assertThat(e.thresholdMaxBasalSpeed).isEqualTo(2.5) + assertThat(e.thresholdMaxBolusDose).isEqualTo(15.0) + assertThat(e.checkSafety).isTrue() + assertThat(e.checkNeedle).isFalse() + assertThat(e.needleFailedCount).isEqualTo(3) + assertThat(e.isConnected).isTrue() + assertThat(e.needDiscard).isFalse() + assertThat(e.isDiscard).isTrue() + assertThat(e.isExtended).isFalse() + assertThat(e.isValid).isTrue() + assertThat(e.isStopped).isFalse() + assertThat(e.stopMinutes).isEqualTo(42) + assertThat(e.stopMode).isEqualTo(2) + assertThat(e.isForceStopped).isTrue() + assertThat(e.runningMinutes).isEqualTo(1234) + assertThat(e.infusedTotalBasalAmount).isEqualTo(12.34) + assertThat(e.infusedTotalBolusAmount).isEqualTo(56.78) + assertThat(e.pumpState).isEqualTo(5) + assertThat(e.mode).isEqualTo(1) + assertThat(e.bolusActionSeq).isEqualTo(7) + } + + @Test + fun `domain to entity serializes dates via toString`() { + val domain = fullDomain() + val e = domain.transformToCarelevoPatchInfoEntity() + assertThat(e.createdAt).isEqualTo(domain.createdAt.toString()) + assertThat(e.updatedAt).isEqualTo(domain.updatedAt.toString()) + // Canonical inputs remain canonical after serialization. + assertThat(e.createdAt).isEqualTo(createdAtStr) + assertThat(e.updatedAt).isEqualTo(updatedAtStr) + } + + @Test + fun `domain to entity preserves all null optionals`() { + val minimal = CarelevoPatchInfoDomainModel( + address = "addr", + createdAt = DateTime.parse(createdAtStr), + updatedAt = DateTime.parse(updatedAtStr) + ) + val e = minimal.transformToCarelevoPatchInfoEntity() + assertThat(e.address).isEqualTo("addr") + assertThat(e.manufactureNumber).isNull() + assertThat(e.firmwareVersion).isNull() + assertThat(e.bootDateTime).isNull() + assertThat(e.bootDateTimeUtcMillis).isNull() + assertThat(e.modelName).isNull() + assertThat(e.insulinAmount).isNull() + assertThat(e.insulinRemain).isNull() + assertThat(e.thresholdInsulinRemain).isNull() + assertThat(e.thresholdExpiry).isNull() + assertThat(e.thresholdMaxBasalSpeed).isNull() + assertThat(e.thresholdMaxBolusDose).isNull() + assertThat(e.checkSafety).isNull() + assertThat(e.checkNeedle).isNull() + assertThat(e.needleFailedCount).isNull() + assertThat(e.isConnected).isNull() + assertThat(e.needDiscard).isNull() + assertThat(e.isDiscard).isNull() + assertThat(e.isExtended).isNull() + assertThat(e.isValid).isNull() + assertThat(e.isStopped).isNull() + assertThat(e.stopMinutes).isNull() + assertThat(e.stopMode).isNull() + assertThat(e.isForceStopped).isNull() + assertThat(e.runningMinutes).isNull() + assertThat(e.infusedTotalBasalAmount).isNull() + assertThat(e.infusedTotalBolusAmount).isNull() + assertThat(e.pumpState).isNull() + assertThat(e.mode).isNull() + assertThat(e.bolusActionSeq).isNull() + } + + // ---------- Round trips ---------- + + @Test + fun `round trip domain to entity to domain preserves values`() { + val original = fullDomain() + val result = original.transformToCarelevoPatchInfoEntity().transformToCarelevoPatchInfoDomainModel() + // Instant preserved (chronology/zone may normalize to fixed offset). + assertThat(result.createdAt.isEqual(original.createdAt)).isTrue() + assertThat(result.updatedAt.isEqual(original.updatedAt)).isTrue() + // All non-date fields unchanged -> compare after normalizing the dates away. + assertThat(result.copy(createdAt = original.createdAt, updatedAt = original.updatedAt)).isEqualTo(original) + } + + @Test + fun `round trip entity to domain to entity preserves canonical strings and values`() { + val original = fullEntity() + val result = original.transformToCarelevoPatchInfoDomainModel().transformToCarelevoPatchInfoEntity() + // Canonical date strings survive the parse -> toString cycle exactly. + assertThat(result).isEqualTo(original) + } + + @Test + fun `round trip domain to entity to domain preserves null optionals`() { + val original = CarelevoPatchInfoDomainModel( + address = "addr", + createdAt = DateTime.parse(createdAtStr), + updatedAt = DateTime.parse(updatedAtStr) + ) + val result = original.transformToCarelevoPatchInfoEntity().transformToCarelevoPatchInfoDomainModel() + assertThat(result.copy(createdAt = original.createdAt, updatedAt = original.updatedAt)).isEqualTo(original) + } + + @Test + fun `boolean flags map with inverted pattern`() { + val e = fullEntity().copy( + checkSafety = false, + checkNeedle = true, + isConnected = false, + needDiscard = true, + isDiscard = false, + isExtended = true, + isValid = false, + isStopped = true, + isForceStopped = false + ) + val d = e.transformToCarelevoPatchInfoDomainModel() + assertThat(d.checkSafety).isFalse() + assertThat(d.checkNeedle).isTrue() + assertThat(d.isConnected).isFalse() + assertThat(d.needDiscard).isTrue() + assertThat(d.isDiscard).isFalse() + assertThat(d.isExtended).isTrue() + assertThat(d.isValid).isFalse() + assertThat(d.isStopped).isTrue() + assertThat(d.isForceStopped).isFalse() + } + + @Test + fun `negative and zero numeric values pass through unchanged`() { + val e = fullEntity().copy( + insulinAmount = 0, + insulinRemain = 0.0, + needleFailedCount = -1, + stopMinutes = 0, + stopMode = -5, + runningMinutes = 0, + infusedTotalBasalAmount = -0.5, + infusedTotalBolusAmount = 0.0, + pumpState = 0, + mode = -1, + bolusActionSeq = 0 + ) + val d = e.transformToCarelevoPatchInfoDomainModel() + assertThat(d.insulinAmount).isEqualTo(0) + assertThat(d.insulinRemain).isEqualTo(0.0) + assertThat(d.needleFailedCount).isEqualTo(-1) + assertThat(d.stopMinutes).isEqualTo(0) + assertThat(d.stopMode).isEqualTo(-5) + assertThat(d.runningMinutes).isEqualTo(0) + assertThat(d.infusedTotalBasalAmount).isEqualTo(-0.5) + assertThat(d.infusedTotalBolusAmount).isEqualTo(0.0) + assertThat(d.pumpState).isEqualTo(0) + assertThat(d.mode).isEqualTo(-1) + assertThat(d.bolusActionSeq).isEqualTo(0) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoUserSettingInfoMapperTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoUserSettingInfoMapperTest.kt new file mode 100644 index 000000000000..646f83df8ac6 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/mapper/CarelevoUserSettingInfoMapperTest.kt @@ -0,0 +1,160 @@ +package app.aaps.pump.carelevo.data.mapper + +import app.aaps.pump.carelevo.data.model.entities.CarelevoUserSettingInfoEntity +import app.aaps.pump.carelevo.domain.model.userSetting.CarelevoUserSettingInfoDomainModel +import com.google.common.truth.Truth.assertThat +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class CarelevoUserSettingInfoMapperTest { + + // Canonical ISO-8601 strings (Joda DateTime.toString() form) so the entity round-trip is exact. + private val createdAtStr = "2024-03-10T12:00:00.000+02:00" + private val updatedAtStr = "2024-03-11T18:45:15.250+02:00" + + private fun fullEntity() = CarelevoUserSettingInfoEntity( + createdAt = createdAtStr, + updatedAt = updatedAtStr, + lowInsulinNoticeAmount = 30, + maxBasalSpeed = 2.5, + maxBolusDose = 15.0, + needLowInsulinNoticeAmountSyncPatch = true, + needMaxBasalSpeedSyncPatch = false, + needMaxBolusDoseSyncPatch = true + ) + + private fun fullDomain() = CarelevoUserSettingInfoDomainModel( + createdAt = DateTime.parse(createdAtStr), + updatedAt = DateTime.parse(updatedAtStr), + lowInsulinNoticeAmount = 30, + maxBasalSpeed = 2.5, + maxBolusDose = 15.0, + needLowInsulinNoticeAmountSyncPatch = true, + needMaxBasalSpeedSyncPatch = false, + needMaxBolusDoseSyncPatch = true + ) + + // ---------- Entity -> Domain ---------- + + @Test + fun `entity to domain maps every scalar field`() { + val d = fullEntity().transformToCarelevoUserSettingInfoDomainModel() + assertThat(d.lowInsulinNoticeAmount).isEqualTo(30) + assertThat(d.maxBasalSpeed).isEqualTo(2.5) + assertThat(d.maxBolusDose).isEqualTo(15.0) + assertThat(d.needLowInsulinNoticeAmountSyncPatch).isTrue() + assertThat(d.needMaxBasalSpeedSyncPatch).isFalse() + assertThat(d.needMaxBolusDoseSyncPatch).isTrue() + } + + @Test + fun `entity to domain parses createdAt and updatedAt strings`() { + val d = fullEntity().transformToCarelevoUserSettingInfoDomainModel() + assertThat(d.createdAt).isEqualTo(DateTime.parse(createdAtStr)) + assertThat(d.updatedAt).isEqualTo(DateTime.parse(updatedAtStr)) + assertThat(d.updatedAt.isEqual(DateTime.parse(updatedAtStr))).isTrue() + } + + @Test + fun `entity to domain preserves null optionals and default false flags`() { + val minimal = CarelevoUserSettingInfoEntity(createdAt = createdAtStr, updatedAt = updatedAtStr) + val d = minimal.transformToCarelevoUserSettingInfoDomainModel() + assertThat(d.lowInsulinNoticeAmount).isNull() + assertThat(d.maxBasalSpeed).isNull() + assertThat(d.maxBolusDose).isNull() + assertThat(d.needLowInsulinNoticeAmountSyncPatch).isFalse() + assertThat(d.needMaxBasalSpeedSyncPatch).isFalse() + assertThat(d.needMaxBolusDoseSyncPatch).isFalse() + } + + @Test + fun `entity to domain throws on unparseable createdAt`() { + val bad = CarelevoUserSettingInfoEntity(createdAt = "nope", updatedAt = updatedAtStr) + assertFailsWith { bad.transformToCarelevoUserSettingInfoDomainModel() } + } + + @Test + fun `entity to domain throws on unparseable updatedAt`() { + val bad = CarelevoUserSettingInfoEntity(createdAt = createdAtStr, updatedAt = "") + assertFailsWith { bad.transformToCarelevoUserSettingInfoDomainModel() } + } + + // ---------- Domain -> Entity ---------- + + @Test + fun `domain to entity maps every scalar field`() { + val e = fullDomain().transformToCarelevoUserSettingInfoEntity() + assertThat(e.lowInsulinNoticeAmount).isEqualTo(30) + assertThat(e.maxBasalSpeed).isEqualTo(2.5) + assertThat(e.maxBolusDose).isEqualTo(15.0) + assertThat(e.needLowInsulinNoticeAmountSyncPatch).isTrue() + assertThat(e.needMaxBasalSpeedSyncPatch).isFalse() + assertThat(e.needMaxBolusDoseSyncPatch).isTrue() + } + + @Test + fun `domain to entity serializes dates via toString`() { + val domain = fullDomain() + val e = domain.transformToCarelevoUserSettingInfoEntity() + assertThat(e.createdAt).isEqualTo(domain.createdAt.toString()) + assertThat(e.updatedAt).isEqualTo(domain.updatedAt.toString()) + assertThat(e.createdAt).isEqualTo(createdAtStr) + assertThat(e.updatedAt).isEqualTo(updatedAtStr) + } + + @Test + fun `domain to entity preserves null optionals and default false flags`() { + val minimal = CarelevoUserSettingInfoDomainModel( + createdAt = DateTime.parse(createdAtStr), + updatedAt = DateTime.parse(updatedAtStr) + ) + val e = minimal.transformToCarelevoUserSettingInfoEntity() + assertThat(e.lowInsulinNoticeAmount).isNull() + assertThat(e.maxBasalSpeed).isNull() + assertThat(e.maxBolusDose).isNull() + assertThat(e.needLowInsulinNoticeAmountSyncPatch).isFalse() + assertThat(e.needMaxBasalSpeedSyncPatch).isFalse() + assertThat(e.needMaxBolusDoseSyncPatch).isFalse() + } + + // ---------- Round trips ---------- + + @Test + fun `round trip domain to entity to domain preserves values`() { + val original = fullDomain() + val result = original.transformToCarelevoUserSettingInfoEntity().transformToCarelevoUserSettingInfoDomainModel() + assertThat(result.createdAt.isEqual(original.createdAt)).isTrue() + assertThat(result.updatedAt.isEqual(original.updatedAt)).isTrue() + assertThat(result.copy(createdAt = original.createdAt, updatedAt = original.updatedAt)).isEqualTo(original) + } + + @Test + fun `round trip entity to domain to entity preserves canonical strings and values`() { + val original = fullEntity() + val result = original.transformToCarelevoUserSettingInfoDomainModel().transformToCarelevoUserSettingInfoEntity() + assertThat(result).isEqualTo(original) + } + + @Test + fun `sync flags map with inverted pattern`() { + val e = fullEntity().copy( + needLowInsulinNoticeAmountSyncPatch = false, + needMaxBasalSpeedSyncPatch = true, + needMaxBolusDoseSyncPatch = false + ) + val d = e.transformToCarelevoUserSettingInfoDomainModel() + assertThat(d.needLowInsulinNoticeAmountSyncPatch).isFalse() + assertThat(d.needMaxBasalSpeedSyncPatch).isTrue() + assertThat(d.needMaxBolusDoseSyncPatch).isFalse() + } + + @Test + fun `zero and negative numeric values pass through unchanged`() { + val e = fullEntity().copy(lowInsulinNoticeAmount = 0, maxBasalSpeed = 0.0, maxBolusDose = -1.5) + val d = e.transformToCarelevoUserSettingInfoDomainModel() + assertThat(d.lowInsulinNoticeAmount).isEqualTo(0) + assertThat(d.maxBasalSpeed).isEqualTo(0.0) + assertThat(d.maxBolusDose).isEqualTo(-1.5) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/model/ble/ProtocolBtModelsTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/model/ble/ProtocolBtModelsTest.kt new file mode 100644 index 000000000000..428b34ff56aa --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/model/ble/ProtocolBtModelsTest.kt @@ -0,0 +1,444 @@ +package app.aaps.pump.carelevo.data.model.ble + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * Pure-logic tests for the Carelevo BLE protocol model/response value classes. + * + * These are all data classes / a sealed class with no parse or derive logic, so coverage + * is driven by construction, property access, the [ProtocolRspModel] interface contract, + * and the compiler-generated equals/hashCode/copy/toString/componentN members. + */ +internal class ProtocolBtModelsTest { + + // --------------------------------------------------------------------------------------------- + // ProtocolSegmentModel (request model) + // --------------------------------------------------------------------------------------------- + + @Test + fun `ProtocolSegmentModel exposes its fields`() { + val model = ProtocolSegmentModel(injectHour = 3, injectMin = 45, injectSpeed = 1.25) + assertThat(model.injectHour).isEqualTo(3) + assertThat(model.injectMin).isEqualTo(45) + assertThat(model.injectSpeed).isEqualTo(1.25) + } + + @Test + fun `ProtocolSegmentModel is a ProtocolRequestModel`() { + val model: ProtocolRequestModel = ProtocolSegmentModel(0, 0, 0.0) + assertThat(model).isInstanceOf(ProtocolSegmentModel::class.java) + } + + @Test + fun `ProtocolSegmentModel equals hashCode copy componentN`() { + val a = ProtocolSegmentModel(1, 2, 3.0) + val b = ProtocolSegmentModel(1, 2, 3.0) + assertThat(a).isEqualTo(b) + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + assertThat(a.copy(injectMin = 9)).isEqualTo(ProtocolSegmentModel(1, 9, 3.0)) + assertThat(a).isNotEqualTo(ProtocolSegmentModel(9, 2, 3.0)) + assertThat(a.component1()).isEqualTo(1) + assertThat(a.component2()).isEqualTo(2) + assertThat(a.component3()).isEqualTo(3.0) + assertThat(a.toString()).contains("injectSpeed=3.0") + } + + // --------------------------------------------------------------------------------------------- + // ProtocolRspModel interface contract — every response carries timestamp + command + // --------------------------------------------------------------------------------------------- + + @Test + fun `all rsp models expose timestamp and command via interface`() { + val ts = 1_700_000_000_000L + val cmd = 0x42 + val models: List = listOf( + ProtocolSetTimeRspModel(ts, cmd, result = 0), + ProtocolAppAuthKeyAckRspModel(ts, cmd, value = 1), + ProtocolAppAuthAckRspModel(ts, cmd, result = 0), + ProtocolSafetyCheckRspModel(ts, cmd, result = 0, insulinVolume = 200, durationSeconds = 30), + ProtocolInfusionThresholdRspModel(ts, cmd, type = 1, result = 0), + ProtocolBuzzUsageChangeRspModel(ts, cmd, result = 0), + ProtocolCannulaInsertionStatusRspModel(ts, cmd, result = 0), + ProtocolCannulaInsertionAckRspModel(ts, cmd, result = 0), + ProtocolPatchThresholdSetRspModel(ts, cmd, result = 0), + ProtocolPatchAlertAlarmSetRspModel(ts, cmd, result = 0), + ProtocolNoticeThresholdRspModel(ts, cmd, result = 0, type = 2), + ProtocolPatchExpiryExtendRspModel(ts, cmd, result = 0), + ProtocolPumpStopRspModel(ts, cmd, result = 0), + ProtocolPumpResumeRspModel(ts, cmd, result = 0, mode = 1, subId = 2), + ProtocolPumpStopRptModel(ts, cmd, result = 0, cause = 1, mode = 2, subId = 3, completedBolusInfusionVolume = 1.5, unInfusedExtendBolusVolume = 0.5, temperature = 25), + ProtocolInfusionStatusInquiryRptModel(ts, cmd, subId = 1, patchRunningTime = 100, insulinRemains = 150.0, infusedTotalBasalAmount = 10.0, infusedTotalBolusAmount = 5.0, pumpState = 1, mode = 2, infusedSetMin = 60, currentInfusedProgramVolume = 2.0, realInfusedTime = 55), + ProtocolPatchInformationInquiryRptModel(ts, cmd, result = 0, serialNum = "SN123"), + ProtocolPatchInformationInquiryDetailRptModel(ts, cmd, result = 0, firmVersion = "1.0.0", bootDateTime = "2026-07-16", modelName = "CL-1"), + ProtocolThresholdRetrieveRspModel(ts, cmd, result = 0, insulinDeficiencyAlarmThreshold = 20, expiryAlarmThreshold = 72), + ProtocolPatchDiscardRspModel(ts, cmd, result = 0), + ProtocolPatchBuzzInspectionRspModel(ts, cmd, result = 0), + ProtocolPatchOperationDataRspModel(ts, cmd, mode = 1, pulseCnt = 10, totalNo = 5, count = 2, useMin = 30, remains = 99.5), + ProtocolAppStatusRspModel(ts, cmd, status = 1), + ProtocolGlucoseMeasurementAlarmTimerRspModel(ts, cmd, timerId = 3, minutes = 15), + ProtocolGlucoseTimerForCGMRspModel(ts, cmd, result = 0, triggerType = 1), + ProtocolGlucoseTimerRptModel(ts, cmd), + ProtocolPatchAddressRspModel(ts, cmd, macAddress = "AA:BB:CC:DD:EE:FF", checkSum = "1F"), + ProtocolWarningMsgRptModel(ts, cmd, cause = 1, value = 10), + ProtocolAlertMsgRptModel(ts, cmd, cause = 2, value = 20), + ProtocolNoticeMsgRptModel(ts, cmd, cause = 3, value = 30), + ProtocolMsgSolutionRspModel(ts, cmd, result = 0, subId = 1, cause = 2), + ProtocolPatchInitRspModel(ts, cmd, mode = 1), + ProtocolPatchRecoveryRptModel(ts, cmd), + ProtocolAdditionalPrimingRspModel(ts, cmd, result = 0), + ProtocolBasalProgramSetRspModel(ts, cmd, result = 0), + ProtocolAdditionalBasalProgramSetRspModel(ts, cmd, result = 0), + ProtocolBasalInfusionChangeRspModel(ts, cmd, result = 0), + ProtocolAdditionalBasalInfusionChangeRspModel(ts, cmd, result = 0), + ProtocolBasalInfusionResumeRspModel(ts, cmd, segmentNo = 1, infusionSpeed = 1.0, infusionPeriod = 30, insulinRemains = 100.0), + ProtocolTempBasalInfusionRspModel(ts, cmd, result = 0), + ProtocolBasalInfusionStartRspModel(ts, cmd), + ProtocolTempBasalInfusionCancelRspModel(ts, cmd, result = 0), + ProtocolImmeBolusInfusionRspModel(ts, cmd, actionId = 7, result = 0, expectedTime = 120, remains = 99.0), + ProtocolExtendBolusInfusionRspModel(ts, cmd, result = 0, expectedTime = 300), + ProtocolExtendBolusInfusionCancelRspModel(ts, cmd, result = 0, infusedAmount = 1.5), + ProtocolBolusInfusionCancelRspModel(ts, cmd, result = 0, insulinRemains = 98.0, infusedAmount = 2.0), + ProtocolExtendBolusDelayRptModel(ts, cmd, delayedAmount = 0.5, expectedTime = 60) + ) + assertThat(models).hasSize(47) + models.forEach { + assertThat(it.timestamp).isEqualTo(ts) + assertThat(it.command).isEqualTo(cmd) + } + } + + // --------------------------------------------------------------------------------------------- + // Field-level assertions for models carrying extra payload + // --------------------------------------------------------------------------------------------- + + @Test + fun `ProtocolSetTimeRspModel fields`() { + val m = ProtocolSetTimeRspModel(1L, 2, result = 3) + assertThat(m.result).isEqualTo(3) + assertThat(m.copy(result = 9).result).isEqualTo(9) + assertThat(m).isEqualTo(ProtocolSetTimeRspModel(1L, 2, 3)) + } + + @Test + fun `ProtocolAppAuthKeyAckRspModel fields`() { + val m = ProtocolAppAuthKeyAckRspModel(1L, 2, value = 5) + assertThat(m.value).isEqualTo(5) + assertThat(m).isNotEqualTo(ProtocolAppAuthKeyAckRspModel(1L, 2, value = 6)) + } + + @Test + fun `ProtocolAppAuthAckRspModel fields`() { + assertThat(ProtocolAppAuthAckRspModel(1L, 2, result = 7).result).isEqualTo(7) + } + + @Test + fun `ProtocolSafetyCheckRspModel fields`() { + val m = ProtocolSafetyCheckRspModel(1L, 2, result = 0, insulinVolume = 200, durationSeconds = 45) + assertThat(m.result).isEqualTo(0) + assertThat(m.insulinVolume).isEqualTo(200) + assertThat(m.durationSeconds).isEqualTo(45) + } + + @Test + fun `ProtocolInfusionThresholdRspModel fields`() { + val m = ProtocolInfusionThresholdRspModel(1L, 2, type = 4, result = 0) + assertThat(m.type).isEqualTo(4) + assertThat(m.result).isEqualTo(0) + } + + @Test + fun `ProtocolNoticeThresholdRspModel fields`() { + val m = ProtocolNoticeThresholdRspModel(1L, 2, result = 1, type = 8) + assertThat(m.result).isEqualTo(1) + assertThat(m.type).isEqualTo(8) + } + + @Test + fun `ProtocolPumpResumeRspModel fields`() { + val m = ProtocolPumpResumeRspModel(1L, 2, result = 0, mode = 3, subId = 4) + assertThat(m.mode).isEqualTo(3) + assertThat(m.subId).isEqualTo(4) + } + + @Test + fun `ProtocolPumpStopRptModel fields`() { + val m = ProtocolPumpStopRptModel( + timestamp = 1L, command = 2, result = 0, cause = 5, mode = 1, subId = 2, + completedBolusInfusionVolume = 3.5, unInfusedExtendBolusVolume = 1.25, temperature = 30 + ) + assertThat(m.cause).isEqualTo(5) + assertThat(m.mode).isEqualTo(1) + assertThat(m.subId).isEqualTo(2) + assertThat(m.completedBolusInfusionVolume).isEqualTo(3.5) + assertThat(m.unInfusedExtendBolusVolume).isEqualTo(1.25) + assertThat(m.temperature).isEqualTo(30) + } + + @Test + fun `ProtocolInfusionStatusInquiryRptModel fields`() { + val m = ProtocolInfusionStatusInquiryRptModel( + timestamp = 1L, command = 2, subId = 3, patchRunningTime = 200, insulinRemains = 150.5, + infusedTotalBasalAmount = 10.5, infusedTotalBolusAmount = 5.25, pumpState = 2, mode = 1, + infusedSetMin = 120, currentInfusedProgramVolume = 2.75, realInfusedTime = 118 + ) + assertThat(m.subId).isEqualTo(3) + assertThat(m.patchRunningTime).isEqualTo(200) + assertThat(m.insulinRemains).isEqualTo(150.5) + assertThat(m.infusedTotalBasalAmount).isEqualTo(10.5) + assertThat(m.infusedTotalBolusAmount).isEqualTo(5.25) + assertThat(m.pumpState).isEqualTo(2) + assertThat(m.mode).isEqualTo(1) + assertThat(m.infusedSetMin).isEqualTo(120) + assertThat(m.currentInfusedProgramVolume).isEqualTo(2.75) + assertThat(m.realInfusedTime).isEqualTo(118) + } + + @Test + fun `ProtocolPatchInformationInquiryRptModel fields`() { + val m = ProtocolPatchInformationInquiryRptModel(1L, 2, result = 0, serialNum = "SN-9") + assertThat(m.serialNum).isEqualTo("SN-9") + assertThat(m.result).isEqualTo(0) + } + + @Test + fun `ProtocolPatchInformationInquiryDetailRptModel fields`() { + val m = ProtocolPatchInformationInquiryDetailRptModel( + 1L, 2, result = 0, firmVersion = "2.1.0", bootDateTime = "2026-07-16 10:00", modelName = "CL-Pro" + ) + assertThat(m.firmVersion).isEqualTo("2.1.0") + assertThat(m.bootDateTime).isEqualTo("2026-07-16 10:00") + assertThat(m.modelName).isEqualTo("CL-Pro") + } + + @Test + fun `ProtocolThresholdRetrieveRspModel fields`() { + val m = ProtocolThresholdRetrieveRspModel(1L, 2, result = 0, insulinDeficiencyAlarmThreshold = 25, expiryAlarmThreshold = 72) + assertThat(m.insulinDeficiencyAlarmThreshold).isEqualTo(25) + assertThat(m.expiryAlarmThreshold).isEqualTo(72) + } + + @Test + fun `ProtocolPatchOperationDataRspModel fields`() { + val m = ProtocolPatchOperationDataRspModel(1L, 2, mode = 1, pulseCnt = 12, totalNo = 5, count = 2, useMin = 30, remains = 88.5) + assertThat(m.mode).isEqualTo(1) + assertThat(m.pulseCnt).isEqualTo(12) + assertThat(m.totalNo).isEqualTo(5) + assertThat(m.count).isEqualTo(2) + assertThat(m.useMin).isEqualTo(30) + assertThat(m.remains).isEqualTo(88.5) + } + + @Test + fun `ProtocolAppStatusRspModel fields`() { + assertThat(ProtocolAppStatusRspModel(1L, 2, status = 9).status).isEqualTo(9) + } + + @Test + fun `ProtocolGlucoseMeasurementAlarmTimerRspModel fields`() { + val m = ProtocolGlucoseMeasurementAlarmTimerRspModel(1L, 2, timerId = 4, minutes = 15) + assertThat(m.timerId).isEqualTo(4) + assertThat(m.minutes).isEqualTo(15) + } + + @Test + fun `ProtocolGlucoseTimerForCGMRspModel fields`() { + val m = ProtocolGlucoseTimerForCGMRspModel(1L, 2, result = 0, triggerType = 3) + assertThat(m.result).isEqualTo(0) + assertThat(m.triggerType).isEqualTo(3) + } + + @Test + fun `ProtocolPatchAddressRspModel fields`() { + val m = ProtocolPatchAddressRspModel(1L, 2, macAddress = "AA:BB:CC:DD:EE:FF", checkSum = "3C") + assertThat(m.macAddress).isEqualTo("AA:BB:CC:DD:EE:FF") + assertThat(m.checkSum).isEqualTo("3C") + } + + @Test + fun `ProtocolWarningMsgRptModel fields`() { + val m = ProtocolWarningMsgRptModel(1L, 2, cause = 11, value = 22) + assertThat(m.cause).isEqualTo(11) + assertThat(m.value).isEqualTo(22) + } + + @Test + fun `ProtocolAlertMsgRptModel fields`() { + val m = ProtocolAlertMsgRptModel(1L, 2, cause = 33, value = 44) + assertThat(m.cause).isEqualTo(33) + assertThat(m.value).isEqualTo(44) + } + + @Test + fun `ProtocolNoticeMsgRptModel fields`() { + val m = ProtocolNoticeMsgRptModel(1L, 2, cause = 55, value = 66) + assertThat(m.cause).isEqualTo(55) + assertThat(m.value).isEqualTo(66) + } + + @Test + fun `three message rpt models with equal payload are still distinct types`() { + val warning = ProtocolWarningMsgRptModel(1L, 2, cause = 1, value = 1) + val alert = ProtocolAlertMsgRptModel(1L, 2, cause = 1, value = 1) + val notice = ProtocolNoticeMsgRptModel(1L, 2, cause = 1, value = 1) + assertThat(warning).isNotEqualTo(alert) + assertThat(alert).isNotEqualTo(notice) + assertThat(warning).isNotEqualTo(notice) + } + + @Test + fun `ProtocolMsgSolutionRspModel fields`() { + val m = ProtocolMsgSolutionRspModel(1L, 2, result = 0, subId = 3, cause = 7) + assertThat(m.result).isEqualTo(0) + assertThat(m.subId).isEqualTo(3) + assertThat(m.cause).isEqualTo(7) + } + + @Test + fun `ProtocolPatchInitRspModel fields`() { + assertThat(ProtocolPatchInitRspModel(1L, 2, mode = 4).mode).isEqualTo(4) + } + + @Test + fun `ProtocolBasalInfusionResumeRspModel fields`() { + val m = ProtocolBasalInfusionResumeRspModel(1L, 2, segmentNo = 3, infusionSpeed = 1.5, infusionPeriod = 60, insulinRemains = 120.0) + assertThat(m.segmentNo).isEqualTo(3) + assertThat(m.infusionSpeed).isEqualTo(1.5) + assertThat(m.infusionPeriod).isEqualTo(60) + assertThat(m.insulinRemains).isEqualTo(120.0) + } + + @Test + fun `ProtocolImmeBolusInfusionRspModel fields`() { + val m = ProtocolImmeBolusInfusionRspModel(1L, 2, actionId = 8, result = 0, expectedTime = 150, remains = 95.5) + assertThat(m.actionId).isEqualTo(8) + assertThat(m.result).isEqualTo(0) + assertThat(m.expectedTime).isEqualTo(150) + assertThat(m.remains).isEqualTo(95.5) + } + + @Test + fun `ProtocolExtendBolusInfusionRspModel fields`() { + val m = ProtocolExtendBolusInfusionRspModel(1L, 2, result = 0, expectedTime = 900) + assertThat(m.result).isEqualTo(0) + assertThat(m.expectedTime).isEqualTo(900) + } + + @Test + fun `ProtocolExtendBolusInfusionCancelRspModel fields`() { + val m = ProtocolExtendBolusInfusionCancelRspModel(1L, 2, result = 0, infusedAmount = 1.75) + assertThat(m.result).isEqualTo(0) + assertThat(m.infusedAmount).isEqualTo(1.75) + } + + @Test + fun `ProtocolBolusInfusionCancelRspModel fields`() { + val m = ProtocolBolusInfusionCancelRspModel(1L, 2, result = 0, insulinRemains = 97.5, infusedAmount = 2.5) + assertThat(m.result).isEqualTo(0) + assertThat(m.insulinRemains).isEqualTo(97.5) + assertThat(m.infusedAmount).isEqualTo(2.5) + } + + @Test + fun `ProtocolExtendBolusDelayRptModel fields`() { + val m = ProtocolExtendBolusDelayRptModel(1L, 2, delayedAmount = 0.75, expectedTime = 120) + assertThat(m.delayedAmount).isEqualTo(0.75) + assertThat(m.expectedTime).isEqualTo(120) + } + + @Test + fun `result-only rsp models carry their single payload`() { + assertThat(ProtocolBuzzUsageChangeRspModel(1L, 2, result = 1).result).isEqualTo(1) + assertThat(ProtocolCannulaInsertionStatusRspModel(1L, 2, result = 1).result).isEqualTo(1) + assertThat(ProtocolCannulaInsertionAckRspModel(1L, 2, result = 1).result).isEqualTo(1) + assertThat(ProtocolPatchThresholdSetRspModel(1L, 2, result = 1).result).isEqualTo(1) + assertThat(ProtocolPatchAlertAlarmSetRspModel(1L, 2, result = 1).result).isEqualTo(1) + assertThat(ProtocolPatchExpiryExtendRspModel(1L, 2, result = 1).result).isEqualTo(1) + assertThat(ProtocolPumpStopRspModel(1L, 2, result = 1).result).isEqualTo(1) + assertThat(ProtocolPatchDiscardRspModel(1L, 2, result = 1).result).isEqualTo(1) + assertThat(ProtocolPatchBuzzInspectionRspModel(1L, 2, result = 1).result).isEqualTo(1) + assertThat(ProtocolAdditionalPrimingRspModel(1L, 2, result = 1).result).isEqualTo(1) + assertThat(ProtocolBasalProgramSetRspModel(1L, 2, result = 1).result).isEqualTo(1) + assertThat(ProtocolAdditionalBasalProgramSetRspModel(1L, 2, result = 1).result).isEqualTo(1) + assertThat(ProtocolBasalInfusionChangeRspModel(1L, 2, result = 1).result).isEqualTo(1) + assertThat(ProtocolAdditionalBasalInfusionChangeRspModel(1L, 2, result = 1).result).isEqualTo(1) + assertThat(ProtocolTempBasalInfusionRspModel(1L, 2, result = 1).result).isEqualTo(1) + assertThat(ProtocolTempBasalInfusionCancelRspModel(1L, 2, result = 1).result).isEqualTo(1) + } + + @Test + fun `parameterless-payload rpt models only carry timestamp and command`() { + val glucose = ProtocolGlucoseTimerRptModel(1L, 2) + val start = ProtocolBasalInfusionStartRspModel(3L, 4) + val recovery = ProtocolPatchRecoveryRptModel(5L, 6) + assertThat(glucose.timestamp).isEqualTo(1L) + assertThat(glucose.command).isEqualTo(2) + assertThat(start.timestamp).isEqualTo(3L) + assertThat(start.command).isEqualTo(4) + assertThat(recovery.timestamp).isEqualTo(5L) + assertThat(recovery.command).isEqualTo(6) + assertThat(glucose).isEqualTo(ProtocolGlucoseTimerRptModel(1L, 2)) + assertThat(glucose).isNotEqualTo(ProtocolGlucoseTimerRptModel(1L, 3)) + } + + // --------------------------------------------------------------------------------------------- + // BleResponse sealed hierarchy + // --------------------------------------------------------------------------------------------- + + @Test + fun `BleResponse RspResponse wraps typed data`() { + val payload = ProtocolAppStatusRspModel(1L, 2, status = 1) + val response: BleResponse = BleResponse.RspResponse(payload) + assertThat(response).isInstanceOf(BleResponse.RspResponse::class.java) + assertThat((response as BleResponse.RspResponse).data).isSameInstanceAs(payload) + } + + @Test + fun `BleResponse RspResponse equals hashCode copy`() { + val a = BleResponse.RspResponse("payload") + val b = BleResponse.RspResponse("payload") + assertThat(a).isEqualTo(b) + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + assertThat(a.copy(data = "other")).isEqualTo(BleResponse.RspResponse("other")) + assertThat(a.component1()).isEqualTo("payload") + } + + @Test + fun `BleResponse Failure carries a message`() { + val failure: BleResponse = BleResponse.Failure("timeout") + assertThat(failure).isInstanceOf(BleResponse.Failure::class.java) + assertThat((failure as BleResponse.Failure).message).isEqualTo("timeout") + assertThat(failure).isEqualTo(BleResponse.Failure("timeout")) + assertThat(failure).isNotEqualTo(BleResponse.Failure("other")) + assertThat(failure.copy(message = "x").message).isEqualTo("x") + } + + @Test + fun `BleResponse Error carries a throwable`() { + val cause = IllegalStateException("boom") + val error: BleResponse = BleResponse.Error(cause) + assertThat(error).isInstanceOf(BleResponse.Error::class.java) + assertThat((error as BleResponse.Error).e).isSameInstanceAs(cause) + assertThat(error).isEqualTo(BleResponse.Error(cause)) + assertThat(error.copy(e = cause).e).isSameInstanceAs(cause) + } + + @Test + fun `BleResponse subtypes are distinguishable in a when`() { + fun describe(r: BleResponse): String = when (r) { + is BleResponse.RspResponse -> "rsp:${r.data}" + is BleResponse.Failure -> "fail:${r.message}" + is BleResponse.Error -> "err:${r.e.message}" + } + assertThat(describe(BleResponse.RspResponse(7))).isEqualTo("rsp:7") + assertThat(describe(BleResponse.Failure("nope"))).isEqualTo("fail:nope") + assertThat(describe(BleResponse.Error(RuntimeException("bad")))).isEqualTo("err:bad") + } + + @Test + fun `BleResponse RspResponse and Failure are not equal`() { + val rsp: BleResponse = BleResponse.RspResponse("x") + val fail: BleResponse = BleResponse.Failure("x") + assertThat(rsp).isNotEqualTo(fail) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/model/entities/CarelevoEntitiesTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/model/entities/CarelevoEntitiesTest.kt new file mode 100644 index 000000000000..4c6a07c69ca5 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/model/entities/CarelevoEntitiesTest.kt @@ -0,0 +1,431 @@ +package app.aaps.pump.carelevo.data.model.entities + +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * Pure-logic coverage for the Gson (de)serialized Carelevo entity data classes. + * + * These are plain `data class` value objects, so the meaningful surface is the generated + * constructor + defaults, `componentN` destructuring, `copy`, `equals`/`hashCode`, and `toString`. + * Each branch of the generated `equals` is exercised by mutating a single field and asserting the + * two instances are no longer equal. + */ +internal class CarelevoEntitiesTest { + + // --------------------------------------------------------------------------------------------- + // CarelevoInfusionInfoEntity + // --------------------------------------------------------------------------------------------- + + @Test + fun `InfusionInfo default constructor leaves every field null`() { + val info = CarelevoInfusionInfoEntity() + assertThat(info.basalInfusionInfo).isNull() + assertThat(info.tempBasalInfusionInfo).isNull() + assertThat(info.immeBolusInfusionInfo).isNull() + assertThat(info.extendBolusInfusionInfo).isNull() + } + + @Test + fun `InfusionInfo holds the four nested infusion aggregates`() { + val basal = CarelevoBasalInfusionInfoEntity( + infusionId = "b1", address = "AA:BB", mode = 1, createdAt = "c", updatedAt = "u", + segments = emptyList(), isStop = false + ) + val temp = CarelevoTempBasalInfusionInfoEntity("t1", "AA:BB", 2, "c", "u") + val imme = CarelevoImmeBolusInfusionInfoEntity("i1", "AA:BB", 3, "c", "u") + val extend = CarelevoExtendBolusInfusionInfoEntity("e1", "AA:BB", 4, "c", "u") + + val info = CarelevoInfusionInfoEntity(basal, temp, imme, extend) + + assertThat(info.basalInfusionInfo).isSameInstanceAs(basal) + assertThat(info.tempBasalInfusionInfo).isSameInstanceAs(temp) + assertThat(info.immeBolusInfusionInfo).isSameInstanceAs(imme) + assertThat(info.extendBolusInfusionInfo).isSameInstanceAs(extend) + } + + @Test + fun `InfusionInfo copy swaps a single member and keeps equality contract`() { + val original = CarelevoInfusionInfoEntity() + val temp = CarelevoTempBasalInfusionInfoEntity("t1", "AA:BB", 2, "c", "u") + val copy = original.copy(tempBasalInfusionInfo = temp) + + assertThat(copy).isNotEqualTo(original) + assertThat(copy.tempBasalInfusionInfo).isSameInstanceAs(temp) + assertThat(copy.basalInfusionInfo).isNull() + // Copying back to identical values restores equality + hashCode. + assertThat(copy.copy(tempBasalInfusionInfo = null)).isEqualTo(original) + assertThat(copy.copy(tempBasalInfusionInfo = null).hashCode()).isEqualTo(original.hashCode()) + } + + // --------------------------------------------------------------------------------------------- + // CarelevoBasalSegmentInfusionInfoEntity + // --------------------------------------------------------------------------------------------- + + @Test + fun `BasalSegment exposes all values and destructures in order`() { + val seg = CarelevoBasalSegmentInfusionInfoEntity( + createdAt = "created", updatedAt = "updated", startTime = 0, endTime = 60, speed = 1.25 + ) + val (createdAt, updatedAt, startTime, endTime, speed) = seg + assertThat(createdAt).isEqualTo("created") + assertThat(updatedAt).isEqualTo("updated") + assertThat(startTime).isEqualTo(0) + assertThat(endTime).isEqualTo(60) + assertThat(speed).isEqualTo(1.25) + } + + @Test + fun `BasalSegment equals differs when the speed changes`() { + val a = CarelevoBasalSegmentInfusionInfoEntity("c", "u", 0, 60, 1.0) + val b = a.copy(speed = 2.0) + assertThat(b).isNotEqualTo(a) + assertThat(a.copy(speed = 1.0)).isEqualTo(a) + assertThat(a.toString()).contains("speed=1.0") + } + + // --------------------------------------------------------------------------------------------- + // CarelevoBasalInfusionInfoEntity + // --------------------------------------------------------------------------------------------- + + @Test + fun `BasalInfusion carries its segment list and stop flag`() { + val seg = CarelevoBasalSegmentInfusionInfoEntity("c", "u", 0, 30, 0.5) + val basal = CarelevoBasalInfusionInfoEntity( + infusionId = "id-1", address = "AA:BB:CC", mode = 7, createdAt = "c", updatedAt = "u", + segments = listOf(seg), isStop = true + ) + assertThat(basal.infusionId).isEqualTo("id-1") + assertThat(basal.address).isEqualTo("AA:BB:CC") + assertThat(basal.mode).isEqualTo(7) + assertThat(basal.segments).containsExactly(seg) + assertThat(basal.isStop).isTrue() + } + + @Test + fun `BasalInfusion equals differs on isStop and on segments`() { + val seg = CarelevoBasalSegmentInfusionInfoEntity("c", "u", 0, 30, 0.5) + val base = CarelevoBasalInfusionInfoEntity("id", "A", 1, "c", "u", listOf(seg), false) + assertThat(base.copy(isStop = true)).isNotEqualTo(base) + assertThat(base.copy(segments = emptyList())).isNotEqualTo(base) + assertThat(base.copy()).isEqualTo(base) + assertThat(base.copy().hashCode()).isEqualTo(base.hashCode()) + } + + // --------------------------------------------------------------------------------------------- + // CarelevoTempBasalInfusionInfoEntity + // --------------------------------------------------------------------------------------------- + + @Test + fun `TempBasal optional fields default to null`() { + val temp = CarelevoTempBasalInfusionInfoEntity( + infusionId = "t", address = "A", mode = 1, createdAt = "c", updatedAt = "u" + ) + assertThat(temp.percent).isNull() + assertThat(temp.speed).isNull() + assertThat(temp.infusionDurationMin).isNull() + } + + @Test + fun `TempBasal fully-populated retains all optional values`() { + val temp = CarelevoTempBasalInfusionInfoEntity( + "t", "A", 1, "c", "u", percent = 150, speed = 3.0, infusionDurationMin = 90 + ) + assertThat(temp.percent).isEqualTo(150) + assertThat(temp.speed).isEqualTo(3.0) + assertThat(temp.infusionDurationMin).isEqualTo(90) + // equals distinguishes on each optional field. + assertThat(temp.copy(percent = null)).isNotEqualTo(temp) + assertThat(temp.copy(speed = 4.0)).isNotEqualTo(temp) + assertThat(temp.copy(infusionDurationMin = 91)).isNotEqualTo(temp) + } + + // --------------------------------------------------------------------------------------------- + // CarelevoImmeBolusInfusionInfoEntity + // --------------------------------------------------------------------------------------------- + + @Test + fun `ImmeBolus optional fields default to null`() { + val imme = CarelevoImmeBolusInfusionInfoEntity("i", "A", 1, "c", "u") + assertThat(imme.volume).isNull() + assertThat(imme.infusionDurationSeconds).isNull() + } + + @Test + fun `ImmeBolus retains volume and duration and distinguishes on them`() { + val imme = CarelevoImmeBolusInfusionInfoEntity( + "i", "A", 1, "c", "u", volume = 2.5, infusionDurationSeconds = 45 + ) + assertThat(imme.volume).isEqualTo(2.5) + assertThat(imme.infusionDurationSeconds).isEqualTo(45) + assertThat(imme.copy(volume = 2.6)).isNotEqualTo(imme) + assertThat(imme.copy(infusionDurationSeconds = 46)).isNotEqualTo(imme) + val (id, address, mode) = imme + assertThat(id).isEqualTo("i") + assertThat(address).isEqualTo("A") + assertThat(mode).isEqualTo(1) + } + + // --------------------------------------------------------------------------------------------- + // CarelevoExtendBolusInfusionInfoEntity + // --------------------------------------------------------------------------------------------- + + @Test + fun `ExtendBolus optional fields default to null`() { + val extend = CarelevoExtendBolusInfusionInfoEntity("e", "A", 1, "c", "u") + assertThat(extend.volume).isNull() + assertThat(extend.speed).isNull() + assertThat(extend.infusionDurationMin).isNull() + } + + @Test + fun `ExtendBolus retains all values and distinguishes on each optional field`() { + val extend = CarelevoExtendBolusInfusionInfoEntity( + "e", "A", 1, "c", "u", volume = 5.0, speed = 1.0, infusionDurationMin = 120 + ) + assertThat(extend.volume).isEqualTo(5.0) + assertThat(extend.speed).isEqualTo(1.0) + assertThat(extend.infusionDurationMin).isEqualTo(120) + assertThat(extend.copy(volume = 6.0)).isNotEqualTo(extend) + assertThat(extend.copy(speed = 2.0)).isNotEqualTo(extend) + assertThat(extend.copy(infusionDurationMin = 121)).isNotEqualTo(extend) + assertThat(extend.copy()).isEqualTo(extend) + } + + // --------------------------------------------------------------------------------------------- + // CarelevoAlarmInfoEntity + // --------------------------------------------------------------------------------------------- + + @Test + fun `AlarmInfo defaults value to null and occurrenceCount to one`() { + val alarm = CarelevoAlarmInfoEntity( + alarmId = "a1", alarmType = AlarmType.WARNING.code, + cause = AlarmCause.ALARM_WARNING_LOW_INSULIN, + createdAt = "c", updatedAt = "u", acknowledged = false + ) + assertThat(alarm.value).isNull() + assertThat(alarm.occurrenceCount).isEqualTo(1) + assertThat(alarm.acknowledged).isFalse() + assertThat(alarm.cause).isEqualTo(AlarmCause.ALARM_WARNING_LOW_INSULIN) + } + + @Test + fun `AlarmInfo carries an explicit value and occurrenceCount`() { + val alarm = CarelevoAlarmInfoEntity( + alarmId = "a2", alarmType = AlarmType.NOTICE.code, + cause = AlarmCause.ALARM_NOTICE_LGS_FINISHED_HIGH_BG, + value = 5, createdAt = "c", updatedAt = "u", acknowledged = true, occurrenceCount = 3 + ) + assertThat(alarm.value).isEqualTo(5) + assertThat(alarm.occurrenceCount).isEqualTo(3) + assertThat(alarm.acknowledged).isTrue() + } + + @Test + fun `AlarmInfo equals distinguishes on acknowledged, cause, value and occurrenceCount`() { + val base = CarelevoAlarmInfoEntity( + "a", AlarmType.ALERT.code, AlarmCause.ALARM_ALERT_OUT_OF_INSULIN, + value = null, createdAt = "c", updatedAt = "u", acknowledged = false + ) + assertThat(base.copy(acknowledged = true)).isNotEqualTo(base) + assertThat(base.copy(cause = AlarmCause.ALARM_ALERT_LOW_BATTERY)).isNotEqualTo(base) + assertThat(base.copy(value = 1)).isNotEqualTo(base) + assertThat(base.copy(occurrenceCount = 2)).isNotEqualTo(base) + assertThat(base.copy()).isEqualTo(base) + assertThat(base.copy().hashCode()).isEqualTo(base.hashCode()) + } + + // --------------------------------------------------------------------------------------------- + // CarelevoPatchInfoEntity + // --------------------------------------------------------------------------------------------- + + @Test + fun `PatchInfo minimal constructor defaults every optional to null`() { + val patch = CarelevoPatchInfoEntity(address = "AA:BB", createdAt = "c", updatedAt = "u") + assertThat(patch.address).isEqualTo("AA:BB") + assertThat(patch.createdAt).isEqualTo("c") + assertThat(patch.updatedAt).isEqualTo("u") + assertThat(patch.manufactureNumber).isNull() + assertThat(patch.firmwareVersion).isNull() + assertThat(patch.bootDateTime).isNull() + assertThat(patch.bootDateTimeUtcMillis).isNull() + assertThat(patch.modelName).isNull() + assertThat(patch.insulinAmount).isNull() + assertThat(patch.insulinRemain).isNull() + assertThat(patch.thresholdInsulinRemain).isNull() + assertThat(patch.thresholdExpiry).isNull() + assertThat(patch.thresholdMaxBasalSpeed).isNull() + assertThat(patch.thresholdMaxBolusDose).isNull() + assertThat(patch.checkSafety).isNull() + assertThat(patch.checkNeedle).isNull() + assertThat(patch.needleFailedCount).isNull() + assertThat(patch.isConnected).isNull() + assertThat(patch.needDiscard).isNull() + assertThat(patch.isDiscard).isNull() + assertThat(patch.isExtended).isNull() + assertThat(patch.isValid).isNull() + assertThat(patch.isStopped).isNull() + assertThat(patch.stopMinutes).isNull() + assertThat(patch.stopMode).isNull() + assertThat(patch.isForceStopped).isNull() + assertThat(patch.runningMinutes).isNull() + assertThat(patch.infusedTotalBasalAmount).isNull() + assertThat(patch.infusedTotalBolusAmount).isNull() + assertThat(patch.pumpState).isNull() + assertThat(patch.mode).isNull() + assertThat(patch.bolusActionSeq).isNull() + } + + @Test + fun `PatchInfo fully-populated retains representative fields`() { + val patch = CarelevoPatchInfoEntity( + address = "AA:BB:CC:DD", + createdAt = "2026-07-16T00:00:00", + updatedAt = "2026-07-16T01:00:00", + manufactureNumber = "MN-123", + firmwareVersion = "1.2.3", + bootDateTime = "2026-07-15T12:00:00", + bootDateTimeUtcMillis = 1_752_580_800_000L, + modelName = "Carelevo", + insulinAmount = 200, + insulinRemain = 123.4, + thresholdInsulinRemain = 20, + thresholdExpiry = 116, + thresholdMaxBasalSpeed = 2.5, + thresholdMaxBolusDose = 15.0, + checkSafety = true, + checkNeedle = false, + needleFailedCount = 2, + isConnected = true, + needDiscard = false, + isDiscard = false, + isExtended = true, + isValid = true, + isStopped = false, + stopMinutes = 0, + stopMode = 1, + isForceStopped = false, + runningMinutes = 42, + infusedTotalBasalAmount = 10.5, + infusedTotalBolusAmount = 3.25, + pumpState = 4, + mode = 2, + bolusActionSeq = 7 + ) + assertThat(patch.manufactureNumber).isEqualTo("MN-123") + assertThat(patch.firmwareVersion).isEqualTo("1.2.3") + assertThat(patch.bootDateTimeUtcMillis).isEqualTo(1_752_580_800_000L) + assertThat(patch.insulinRemain).isEqualTo(123.4) + assertThat(patch.thresholdMaxBasalSpeed).isEqualTo(2.5) + assertThat(patch.checkSafety).isTrue() + assertThat(patch.checkNeedle).isFalse() + assertThat(patch.isExtended).isTrue() + assertThat(patch.runningMinutes).isEqualTo(42) + assertThat(patch.infusedTotalBasalAmount).isEqualTo(10.5) + assertThat(patch.pumpState).isEqualTo(4) + assertThat(patch.mode).isEqualTo(2) + assertThat(patch.bolusActionSeq).isEqualTo(7) + } + + @Test + fun `PatchInfo copy alters one field, others survive, equality restores`() { + val base = CarelevoPatchInfoEntity(address = "AA:BB", createdAt = "c", updatedAt = "u") + val updated = base.copy(insulinRemain = 50.0, isConnected = true) + assertThat(updated).isNotEqualTo(base) + assertThat(updated.insulinRemain).isEqualTo(50.0) + assertThat(updated.isConnected).isTrue() + assertThat(updated.address).isEqualTo("AA:BB") + // reverting the changed fields restores full equality + hashCode. + val reverted = updated.copy(insulinRemain = null, isConnected = null) + assertThat(reverted).isEqualTo(base) + assertThat(reverted.hashCode()).isEqualTo(base.hashCode()) + } + + @Test + fun `PatchInfo equals distinguishes on nullable boolean and numeric fields`() { + val base = CarelevoPatchInfoEntity(address = "AA:BB", createdAt = "c", updatedAt = "u") + assertThat(base.copy(isValid = false)).isNotEqualTo(base) + assertThat(base.copy(isStopped = true)).isNotEqualTo(base) + assertThat(base.copy(isForceStopped = true)).isNotEqualTo(base) + assertThat(base.copy(needDiscard = true)).isNotEqualTo(base) + assertThat(base.copy(pumpState = 1)).isNotEqualTo(base) + assertThat(base.copy(address = "ZZ:ZZ")).isNotEqualTo(base) + } + + @Test + fun `PatchInfo first three destructured components are the required fields`() { + val patch = CarelevoPatchInfoEntity(address = "AA:BB", createdAt = "c", updatedAt = "u") + assertThat(patch.component1()).isEqualTo("AA:BB") + assertThat(patch.component2()).isEqualTo("c") + assertThat(patch.component3()).isEqualTo("u") + assertThat(patch.toString()).contains("address=AA:BB") + } + + // --------------------------------------------------------------------------------------------- + // CarelevoUserSettingInfoEntity + // --------------------------------------------------------------------------------------------- + + @Test + fun `UserSetting minimal constructor defaults optionals to null and sync flags to false`() { + val setting = CarelevoUserSettingInfoEntity(createdAt = "c", updatedAt = "u") + assertThat(setting.createdAt).isEqualTo("c") + assertThat(setting.updatedAt).isEqualTo("u") + assertThat(setting.lowInsulinNoticeAmount).isNull() + assertThat(setting.maxBasalSpeed).isNull() + assertThat(setting.maxBolusDose).isNull() + assertThat(setting.needLowInsulinNoticeAmountSyncPatch).isFalse() + assertThat(setting.needMaxBasalSpeedSyncPatch).isFalse() + assertThat(setting.needMaxBolusDoseSyncPatch).isFalse() + } + + @Test + fun `UserSetting fully-populated retains all values`() { + val setting = CarelevoUserSettingInfoEntity( + createdAt = "c", + updatedAt = "u", + lowInsulinNoticeAmount = 20, + maxBasalSpeed = 2.5, + maxBolusDose = 15.0, + needLowInsulinNoticeAmountSyncPatch = true, + needMaxBasalSpeedSyncPatch = true, + needMaxBolusDoseSyncPatch = true + ) + assertThat(setting.lowInsulinNoticeAmount).isEqualTo(20) + assertThat(setting.maxBasalSpeed).isEqualTo(2.5) + assertThat(setting.maxBolusDose).isEqualTo(15.0) + assertThat(setting.needLowInsulinNoticeAmountSyncPatch).isTrue() + assertThat(setting.needMaxBasalSpeedSyncPatch).isTrue() + assertThat(setting.needMaxBolusDoseSyncPatch).isTrue() + } + + @Test + fun `UserSetting equals distinguishes on each sync flag and value`() { + val base = CarelevoUserSettingInfoEntity(createdAt = "c", updatedAt = "u") + assertThat(base.copy(needLowInsulinNoticeAmountSyncPatch = true)).isNotEqualTo(base) + assertThat(base.copy(needMaxBasalSpeedSyncPatch = true)).isNotEqualTo(base) + assertThat(base.copy(needMaxBolusDoseSyncPatch = true)).isNotEqualTo(base) + assertThat(base.copy(lowInsulinNoticeAmount = 10)).isNotEqualTo(base) + assertThat(base.copy(maxBasalSpeed = 1.0)).isNotEqualTo(base) + assertThat(base.copy(maxBolusDose = 5.0)).isNotEqualTo(base) + assertThat(base.copy()).isEqualTo(base) + assertThat(base.copy().hashCode()).isEqualTo(base.hashCode()) + } + + @Test + fun `UserSetting destructures in declaration order`() { + val setting = CarelevoUserSettingInfoEntity( + "c", "u", 20, 2.5, 15.0, true, false, true + ) + val (createdAt, updatedAt, low, maxBasal, maxBolus, syncLow, syncBasal, syncBolus) = setting + assertThat(createdAt).isEqualTo("c") + assertThat(updatedAt).isEqualTo("u") + assertThat(low).isEqualTo(20) + assertThat(maxBasal).isEqualTo(2.5) + assertThat(maxBolus).isEqualTo(15.0) + assertThat(syncLow).isTrue() + assertThat(syncBasal).isFalse() + assertThat(syncBolus).isTrue() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoAlarmInfoLocalRepositoryImplTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoAlarmInfoLocalRepositoryImplTest.kt new file mode 100644 index 000000000000..8f36213dafc0 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoAlarmInfoLocalRepositoryImplTest.kt @@ -0,0 +1,143 @@ +package app.aaps.pump.carelevo.data.repository + +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoAlarmInfoLocalDataSource +import app.aaps.pump.carelevo.data.model.entities.CarelevoAlarmInfoEntity +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.Optional + +/** + * Tests for [CarelevoAlarmInfoLocalRepositoryImpl] — delegates to + * [CarelevoAlarmInfoLocalDataSource] and applies the entity <-> domain mappers. Covers the + * present/empty optional branches and the domain->entity mapping on the write paths. + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoAlarmInfoLocalRepositoryImplTest { + + @Mock lateinit var dataSource: CarelevoAlarmInfoLocalDataSource + + private lateinit var sut: CarelevoAlarmInfoLocalRepositoryImpl + + private val created = "2026-07-16T12:00:00.000Z" + private val updated = "2026-07-16T12:05:00.000Z" + + @BeforeEach + fun setUp() { + sut = CarelevoAlarmInfoLocalRepositoryImpl(dataSource) + } + + private fun entity(id: String = "a-1") = CarelevoAlarmInfoEntity( + alarmId = id, alarmType = 1, cause = AlarmCause.ALARM_ALERT_LOW_BATTERY, value = 3, + createdAt = created, updatedAt = updated, acknowledged = false + ) + + private fun domain(id: String = "a-1") = CarelevoAlarmInfo( + alarmId = id, alarmType = AlarmType.ALERT, cause = AlarmCause.ALARM_ALERT_LOW_BATTERY, + value = 3, createdAt = created, updatedAt = updated, isAcknowledged = false + ) + + @Test + fun `observeAlarms maps each entity to a domain model`() { + whenever(dataSource.observeAlarms()).thenReturn(Observable.just(Optional.of(listOf(entity())))) + + val result = sut.observeAlarms().blockingFirst() + + assertThat(result.isPresent).isTrue() + assertThat(result.get()).hasSize(1) + val alarm = result.get().first() + assertThat(alarm.alarmId).isEqualTo("a-1") + assertThat(alarm.alarmType).isEqualTo(AlarmType.ALERT) + assertThat(alarm.cause).isEqualTo(AlarmCause.ALARM_ALERT_LOW_BATTERY) + verify(dataSource).observeAlarms() + } + + @Test + fun `observeAlarms maps an empty optional to an empty list`() { + whenever(dataSource.observeAlarms()).thenReturn(Observable.just(Optional.empty>())) + + val result = sut.observeAlarms().blockingFirst() + + assertThat(result.isPresent).isTrue() + assertThat(result.get()).isEmpty() + } + + @Test + fun `getAlarmsOnce maps each entity to a domain model`() { + whenever(dataSource.getAlarmsOnce()) + .thenReturn(Single.just(Optional.of(listOf(entity("a-1"), entity("a-2"))))) + + val result = sut.getAlarmsOnce().blockingGet() + + assertThat(result.isPresent).isTrue() + assertThat(result.get().map { it.alarmId }).containsExactly("a-1", "a-2").inOrder() + verify(dataSource).getAlarmsOnce() + } + + @Test + fun `getAlarmsOnce maps an empty optional to an empty list`() { + whenever(dataSource.getAlarmsOnce()).thenReturn(Single.just(Optional.empty>())) + + assertThat(sut.getAlarmsOnce().blockingGet().get()).isEmpty() + } + + @Test + fun `setAlarms maps the domain list to entities and forwards it`() { + whenever(dataSource.setAlarms(any())).thenReturn(Completable.complete()) + + sut.setAlarms(listOf(domain("a-1"), domain("a-2"))).blockingAwait() + + val captor = argumentCaptor>() + verify(dataSource).setAlarms(captor.capture()) + assertThat(captor.firstValue.map { it.alarmId }).containsExactly("a-1", "a-2").inOrder() + assertThat(captor.firstValue.first().alarmType).isEqualTo(1) // ALERT -> code 1 + } + + @Test + fun `upsertAlarm maps the domain to an entity and forwards it`() { + whenever(dataSource.upsertAlarm(any())).thenReturn(Completable.complete()) + + sut.upsertAlarm(domain("a-7")).blockingAwait() + + val captor = argumentCaptor() + verify(dataSource).upsertAlarm(captor.capture()) + assertThat(captor.firstValue.alarmId).isEqualTo("a-7") + assertThat(captor.firstValue.alarmType).isEqualTo(1) + assertThat(captor.firstValue.cause).isEqualTo(AlarmCause.ALARM_ALERT_LOW_BATTERY) + } + + @Test + fun `removeAlarm delegates the id to the data source`() { + whenever(dataSource.removeAlarm("a-3")).thenReturn(Completable.complete()) + + sut.removeAlarm("a-3").blockingAwait() + + verify(dataSource).removeAlarm(eq("a-3")) + } + + @Test + fun `clearAlarms delegates to the data source`() { + whenever(dataSource.clearAlarms()).thenReturn(Completable.complete()) + + sut.clearAlarms().blockingAwait() + + verify(dataSource).clearAlarms() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoInfusionInfoRepositoryImplTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoInfusionInfoRepositoryImplTest.kt new file mode 100644 index 000000000000..5671e758dd33 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoInfusionInfoRepositoryImplTest.kt @@ -0,0 +1,193 @@ +package app.aaps.pump.carelevo.data.repository + +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoInfusionInfoDataSource +import app.aaps.pump.carelevo.data.model.entities.CarelevoBasalInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoExtendBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoImmeBolusInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoInfusionInfoEntity +import app.aaps.pump.carelevo.data.model.entities.CarelevoTempBasalInfusionInfoEntity +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoExtendBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoImmeBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoTempBasalInfusionInfoDomainModel +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Observable +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.Optional + +/** + * Tests for [CarelevoInfusionInfoRepositoryImpl] — delegates to [CarelevoInfusionInfoDataSource] and + * applies the entity <-> domain mappers. Covers the nullable/optional branches on the read paths and + * the domain->entity mapping on the write paths. + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoInfusionInfoRepositoryImplTest { + + @Mock lateinit var dataSource: CarelevoInfusionInfoDataSource + + private lateinit var sut: CarelevoInfusionInfoRepositoryImpl + + @BeforeEach + fun setUp() { + sut = CarelevoInfusionInfoRepositoryImpl(dataSource) + } + + private fun basalDomain() = CarelevoBasalInfusionInfoDomainModel( + infusionId = "b-1", address = "AA", mode = 1, segments = emptyList(), isStop = false + ) + + private fun tempBasalDomain() = CarelevoTempBasalInfusionInfoDomainModel( + infusionId = "t-1", address = "AA", mode = 2, percent = 150 + ) + + private fun immeDomain() = CarelevoImmeBolusInfusionInfoDomainModel( + infusionId = "i-1", address = "AA", mode = 3, volume = 2.0 + ) + + private fun extendDomain() = CarelevoExtendBolusInfusionInfoDomainModel( + infusionId = "e-1", address = "AA", mode = 5, volume = 3.0 + ) + + // region read paths + + @Test + fun `getInfusionInfo maps a present entity to a present domain model`() { + whenever(dataSource.getInfusionInfo()) + .thenReturn(Observable.just(Optional.of(CarelevoInfusionInfoEntity()))) + + val result = sut.getInfusionInfo().blockingFirst() + + assertThat(result.isPresent).isTrue() + assertThat(result.get().basalInfusionInfo).isNull() + verify(dataSource).getInfusionInfo() + } + + @Test + fun `getInfusionInfo maps an empty optional to an empty optional`() { + whenever(dataSource.getInfusionInfo()).thenReturn(Observable.just(Optional.empty())) + + assertThat(sut.getInfusionInfo().blockingFirst().isPresent).isFalse() + } + + @Test + fun `getInfusionInfoBySync maps a present entity to a domain model`() { + whenever(dataSource.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoEntity()) + + val result = sut.getInfusionInfoBySync() + + assertThat(result).isNotNull() + assertThat(result!!.basalInfusionInfo).isNull() + verify(dataSource).getInfusionInfoBySync() + } + + @Test + fun `getInfusionInfoBySync returns null when the data source has none`() { + whenever(dataSource.getInfusionInfoBySync()).thenReturn(null) + + assertThat(sut.getInfusionInfoBySync()).isNull() + } + + // endregion + + // region write paths + + @Test + fun `updateBasalInfusionInfo maps to an entity and returns the data source result`() { + val captor = argumentCaptor() + whenever(dataSource.updateBasalInfusionInfo(captor.capture())).thenReturn(true) + + assertThat(sut.updateBasalInfusionInfo(basalDomain())).isTrue() + assertThat(captor.firstValue.infusionId).isEqualTo("b-1") + assertThat(captor.firstValue.mode).isEqualTo(1) + } + + @Test + fun `updateBasalInfusionInfo propagates a false result`() { + whenever(dataSource.updateBasalInfusionInfo(any())).thenReturn(false) + + assertThat(sut.updateBasalInfusionInfo(basalDomain())).isFalse() + } + + @Test + fun `updateTempBasalInfusionInfo maps to an entity and returns the data source result`() { + val captor = argumentCaptor() + whenever(dataSource.updateTempBasalInfusionInfo(captor.capture())).thenReturn(true) + + assertThat(sut.updateTempBasalInfusionInfo(tempBasalDomain())).isTrue() + assertThat(captor.firstValue.infusionId).isEqualTo("t-1") + assertThat(captor.firstValue.percent).isEqualTo(150) + } + + @Test + fun `updateImmeBolusInfusionInfo maps to an entity and returns the data source result`() { + val captor = argumentCaptor() + whenever(dataSource.updateImmeBolusInfusionInfo(captor.capture())).thenReturn(true) + + assertThat(sut.updateImmeBolusInfusionInfo(immeDomain())).isTrue() + assertThat(captor.firstValue.infusionId).isEqualTo("i-1") + assertThat(captor.firstValue.volume).isEqualTo(2.0) + } + + @Test + fun `updateExtendBolusInfusionInfo maps to an entity and returns the data source result`() { + val captor = argumentCaptor() + whenever(dataSource.updateExtendBolusInfusionInfo(captor.capture())).thenReturn(false) + + assertThat(sut.updateExtendBolusInfusionInfo(extendDomain())).isFalse() + assertThat(captor.firstValue.infusionId).isEqualTo("e-1") + assertThat(captor.firstValue.volume).isEqualTo(3.0) + } + + @Test + fun `deleteBasalInfusionInfo delegates and returns data source result`() { + whenever(dataSource.deleteBasalInfusionInfo()).thenReturn(true) + + assertThat(sut.deleteBasalInfusionInfo()).isTrue() + verify(dataSource).deleteBasalInfusionInfo() + } + + @Test + fun `deleteTempBasalInfusionInfo delegates and returns data source result`() { + whenever(dataSource.deleteTempBasalInfusionInfo()).thenReturn(false) + + assertThat(sut.deleteTempBasalInfusionInfo()).isFalse() + verify(dataSource).deleteTempBasalInfusionInfo() + } + + @Test + fun `deleteImmeBolusInfusionInfo delegates and returns data source result`() { + whenever(dataSource.deleteImmeBolusInfusionInfo()).thenReturn(true) + + assertThat(sut.deleteImmeBolusInfusionInfo()).isTrue() + verify(dataSource).deleteImmeBolusInfusionInfo() + } + + @Test + fun `deleteExtendBolusInfusionInfo delegates and returns data source result`() { + whenever(dataSource.deleteExtendBolusInfusionInfo()).thenReturn(true) + + assertThat(sut.deleteExtendBolusInfusionInfo()).isTrue() + verify(dataSource).deleteExtendBolusInfusionInfo() + } + + @Test + fun `deleteInfusionInfo delegates and returns data source result`() { + whenever(dataSource.deleteInfusionInfo()).thenReturn(true) + + assertThat(sut.deleteInfusionInfo()).isTrue() + verify(dataSource).deleteInfusionInfo() + } + + // endregion +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoPatchInfoRepositoryImplTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoPatchInfoRepositoryImplTest.kt new file mode 100644 index 000000000000..0f29b6cf21b2 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoPatchInfoRepositoryImplTest.kt @@ -0,0 +1,113 @@ +package app.aaps.pump.carelevo.data.repository + +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoPatchInfoDataSource +import app.aaps.pump.carelevo.data.model.entities.CarelevoPatchInfoEntity +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Observable +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.Optional + +/** + * Tests for [CarelevoPatchInfoRepositoryImpl] — delegates to [CarelevoPatchInfoDataSource] and + * applies the entity <-> domain mappers. Covers the present/empty branches on the read paths and the + * domain->entity mapping on the update path. + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoPatchInfoRepositoryImplTest { + + @Mock lateinit var dataSource: CarelevoPatchInfoDataSource + + private lateinit var sut: CarelevoPatchInfoRepositoryImpl + + private val created = "2026-07-16T12:00:00.000Z" + private val updated = "2026-07-16T12:05:00.000Z" + + @BeforeEach + fun setUp() { + sut = CarelevoPatchInfoRepositoryImpl(dataSource) + } + + private fun entity() = CarelevoPatchInfoEntity( + address = "AA:BB:CC:DD:EE:FF", createdAt = created, updatedAt = updated, + manufactureNumber = "CARELEVO-001", insulinRemain = 55.0, mode = 1 + ) + + private fun domain() = CarelevoPatchInfoDomainModel( + address = "AA:BB:CC:DD:EE:FF", manufactureNumber = "CARELEVO-001", insulinRemain = 55.0, mode = 1 + ) + + @Test + fun `getPatchInfo maps a present entity to a present domain model`() { + whenever(dataSource.getPatchInfo()).thenReturn(Observable.just(Optional.of(entity()))) + + val result = sut.getPatchInfo().blockingFirst() + + assertThat(result.isPresent).isTrue() + assertThat(result.get().address).isEqualTo("AA:BB:CC:DD:EE:FF") + assertThat(result.get().manufactureNumber).isEqualTo("CARELEVO-001") + assertThat(result.get().insulinRemain).isEqualTo(55.0) + verify(dataSource).getPatchInfo() + } + + @Test + fun `getPatchInfo maps an empty optional to an empty optional`() { + whenever(dataSource.getPatchInfo()).thenReturn(Observable.just(Optional.empty())) + + assertThat(sut.getPatchInfo().blockingFirst().isPresent).isFalse() + } + + @Test + fun `getPatchInfoBySync maps a present entity to a domain model`() { + whenever(dataSource.getPatchInfoBySync()).thenReturn(entity()) + + val result = sut.getPatchInfoBySync() + + assertThat(result).isNotNull() + assertThat(result!!.address).isEqualTo("AA:BB:CC:DD:EE:FF") + verify(dataSource).getPatchInfoBySync() + } + + @Test + fun `getPatchInfoBySync returns null when the data source has none`() { + whenever(dataSource.getPatchInfoBySync()).thenReturn(null) + + assertThat(sut.getPatchInfoBySync()).isNull() + } + + @Test + fun `updatePatchInfo maps to an entity and returns the data source result`() { + val captor = argumentCaptor() + whenever(dataSource.updatePatchInfo(captor.capture())).thenReturn(true) + + assertThat(sut.updatePatchInfo(domain())).isTrue() + assertThat(captor.firstValue.address).isEqualTo("AA:BB:CC:DD:EE:FF") + assertThat(captor.firstValue.manufactureNumber).isEqualTo("CARELEVO-001") + } + + @Test + fun `updatePatchInfo propagates a false result`() { + whenever(dataSource.updatePatchInfo(any())).thenReturn(false) + + assertThat(sut.updatePatchInfo(domain())).isFalse() + } + + @Test + fun `deletePatchInfo delegates and returns data source result`() { + whenever(dataSource.deletePatchInfo()).thenReturn(true) + + assertThat(sut.deletePatchInfo()).isTrue() + verify(dataSource).deletePatchInfo() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoUserSettingInfoRepositoryImplTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoUserSettingInfoRepositoryImplTest.kt new file mode 100644 index 000000000000..f501be143f74 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/data/repository/CarelevoUserSettingInfoRepositoryImplTest.kt @@ -0,0 +1,115 @@ +package app.aaps.pump.carelevo.data.repository + +import app.aaps.pump.carelevo.data.dataSource.local.CarelevoUserSettingInfoDataSource +import app.aaps.pump.carelevo.data.model.entities.CarelevoUserSettingInfoEntity +import app.aaps.pump.carelevo.domain.model.userSetting.CarelevoUserSettingInfoDomainModel +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Observable +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.Optional + +/** + * Tests for [CarelevoUserSettingInfoRepositoryImpl] — delegates to + * [CarelevoUserSettingInfoDataSource] and applies the entity <-> domain mappers. Covers the + * present/null branches on the read paths and the domain->entity mapping on the update path. + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoUserSettingInfoRepositoryImplTest { + + @Mock lateinit var dataSource: CarelevoUserSettingInfoDataSource + + private lateinit var sut: CarelevoUserSettingInfoRepositoryImpl + + private val created = "2026-07-16T12:00:00.000Z" + private val updated = "2026-07-16T12:05:00.000Z" + + @BeforeEach + fun setUp() { + sut = CarelevoUserSettingInfoRepositoryImpl(dataSource) + } + + private fun entity() = CarelevoUserSettingInfoEntity( + createdAt = created, updatedAt = updated, lowInsulinNoticeAmount = 10, + maxBasalSpeed = 3.0, maxBolusDose = 15.0, needMaxBolusDoseSyncPatch = true + ) + + private fun domain() = CarelevoUserSettingInfoDomainModel( + lowInsulinNoticeAmount = 10, maxBasalSpeed = 3.0, maxBolusDose = 15.0, needMaxBolusDoseSyncPatch = true + ) + + @Test + fun `getUserSettingInfo maps a present entity to a present domain model`() { + whenever(dataSource.getUserSettingInfo()).thenReturn(Observable.just(Optional.of(entity()))) + + val result = sut.getUserSettingInfo().blockingFirst() + + assertThat(result.isPresent).isTrue() + assertThat(result.get().lowInsulinNoticeAmount).isEqualTo(10) + assertThat(result.get().maxBasalSpeed).isEqualTo(3.0) + assertThat(result.get().maxBolusDose).isEqualTo(15.0) + assertThat(result.get().needMaxBolusDoseSyncPatch).isTrue() + verify(dataSource).getUserSettingInfo() + } + + @Test + fun `getUserSettingInfo maps an empty optional to an empty optional`() { + whenever(dataSource.getUserSettingInfo()).thenReturn(Observable.just(Optional.empty())) + + assertThat(sut.getUserSettingInfo().blockingFirst().isPresent).isFalse() + } + + @Test + fun `getUserSettingInfoBySync maps a present entity to a domain model`() { + whenever(dataSource.getUserSettingInfoBySync()).thenReturn(entity()) + + val result = sut.getUserSettingInfoBySync() + + assertThat(result).isNotNull() + assertThat(result!!.lowInsulinNoticeAmount).isEqualTo(10) + verify(dataSource).getUserSettingInfoBySync() + } + + @Test + fun `getUserSettingInfoBySync returns null when the data source has none`() { + whenever(dataSource.getUserSettingInfoBySync()).thenReturn(null) + + assertThat(sut.getUserSettingInfoBySync()).isNull() + } + + @Test + fun `updateUserSettingInfo maps to an entity and returns the data source result`() { + val captor = argumentCaptor() + whenever(dataSource.updateUserSettingInfo(captor.capture())).thenReturn(true) + + assertThat(sut.updateUserSettingInfo(domain())).isTrue() + assertThat(captor.firstValue.lowInsulinNoticeAmount).isEqualTo(10) + assertThat(captor.firstValue.maxBolusDose).isEqualTo(15.0) + assertThat(captor.firstValue.needMaxBolusDoseSyncPatch).isTrue() + } + + @Test + fun `updateUserSettingInfo propagates a false result`() { + whenever(dataSource.updateUserSettingInfo(any())).thenReturn(false) + + assertThat(sut.updateUserSettingInfo(domain())).isFalse() + } + + @Test + fun `deleteUserSettingInfo delegates and returns data source result`() { + whenever(dataSource.deleteUserSettingInfo()).thenReturn(true) + + assertThat(sut.deleteUserSettingInfo()).isTrue() + verify(dataSource).deleteUserSettingInfo() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/ext/CarelevoDomainValueExtTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/ext/CarelevoDomainValueExtTest.kt new file mode 100644 index 000000000000..ae7b54f38304 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/ext/CarelevoDomainValueExtTest.kt @@ -0,0 +1,138 @@ +package app.aaps.pump.carelevo.domain.ext + +import app.aaps.pump.carelevo.domain.model.basal.CarelevoBasalSegmentDomainModel +import com.google.common.truth.Truth.assertThat +import java.util.UUID +import org.junit.jupiter.api.Test + +/** + * Pure-logic tests for `CarelevoDomainValueExt.kt` — the minute-based basal segment + * normalizer (`splitSegment`) and `generateUUID`. + * + * `splitSegment` always returns exactly 24 hourly buckets whose start/end are in hours; + * input segment start/end are in MINUTES (divided by 60 internally). + */ +internal class CarelevoDomainValueExtTest { + + private fun seg(startMin: Int, endMin: Int, speed: Double) = + CarelevoBasalSegmentDomainModel(startTime = startMin, endTime = endMin, speed = speed) + + // ---------- splitSegment ---------- + + @Test + fun `splitSegment always returns 24 hourly buckets`() { + assertThat(emptyList().splitSegment()).hasSize(24) + assertThat(listOf(seg(0, 1440, 1.0)).splitSegment()).hasSize(24) + } + + @Test + fun `splitSegment buckets are consecutive one-hour spans`() { + val hourly = listOf(seg(0, 1440, 1.0)).splitSegment() + hourly.forEachIndexed { hour, s -> + assertThat(s.startTime).isEqualTo(hour) + assertThat(s.endTime).isEqualTo(hour + 1) + } + } + + @Test + fun `splitSegment on empty input fills every hour with zero speed`() { + val hourly = emptyList().splitSegment() + assertThat(hourly.map { it.speed }).containsExactlyElementsIn(List(24) { 0.0 }) + } + + @Test + fun `splitSegment spreads a full-day segment across all 24 hours`() { + val hourly = listOf(seg(0, 1440, 2.5)).splitSegment() + assertThat(hourly.map { it.speed }).containsExactlyElementsIn(List(24) { 2.5 }) + assertThat(hourly.first()).isEqualTo(seg(0, 1, 2.5)) + assertThat(hourly.last()).isEqualTo(seg(23, 24, 2.5)) + } + + @Test + fun `splitSegment splits a morning and afternoon segment at noon`() { + val hourly = listOf( + seg(0, 720, 1.0), // 00:00 - 12:00 + seg(720, 1440, 2.0) // 12:00 - 24:00 + ).splitSegment() + + for (hour in 0..11) assertThat(hourly[hour].speed).isEqualTo(1.0) + for (hour in 12..23) assertThat(hourly[hour].speed).isEqualTo(2.0) + } + + @Test + fun `splitSegment leaves uncovered hours at zero speed`() { + // Only 02:00 - 04:00 covered. + val hourly = listOf(seg(120, 240, 3.0)).splitSegment() + assertThat(hourly[0].speed).isEqualTo(0.0) + assertThat(hourly[1].speed).isEqualTo(0.0) + assertThat(hourly[2].speed).isEqualTo(3.0) + assertThat(hourly[3].speed).isEqualTo(3.0) + assertThat(hourly[4].speed).isEqualTo(0.0) + } + + @Test + fun `splitSegment gives the last matching segment priority for overlaps`() { + // seg1 covers 00:00-06:00 @1.0, seg2 covers 02:00-04:00 @9.0. + val hourly = listOf( + seg(0, 360, 1.0), + seg(120, 240, 9.0) + ).splitSegment() + + assertThat(hourly[1].speed).isEqualTo(1.0) // only seg1 + assertThat(hourly[2].speed).isEqualTo(9.0) // overlap -> later-start wins + assertThat(hourly[3].speed).isEqualTo(9.0) // overlap -> later-start wins + assertThat(hourly[5].speed).isEqualTo(1.0) // only seg1 + } + + @Test + fun `splitSegment sorts unsorted input before bucketing`() { + val hourly = listOf( + seg(720, 1440, 2.0), // afternoon first in the list + seg(0, 720, 1.0) // morning second + ).splitSegment() + + assertThat(hourly[0].speed).isEqualTo(1.0) + assertThat(hourly[23].speed).isEqualTo(2.0) + } + + @Test + fun `splitSegment clamps a start hour above 23 into the last hour`() { + // 1500 min -> 25h, clamped start 23; end clamped to 24 -> covers only hour 23. + val hourly = listOf(seg(1500, 1500, 4.0)).splitSegment() + assertThat(hourly[22].speed).isEqualTo(0.0) + assertThat(hourly[23].speed).isEqualTo(4.0) + } + + @Test + fun `splitSegment clamps a negative start hour up to zero`() { + // -120 min -> -2h clamped to 0; end 120 min -> 2h. Covers hours 0 and 1. + val hourly = listOf(seg(-120, 120, 3.0)).splitSegment() + assertThat(hourly[0].speed).isEqualTo(3.0) + assertThat(hourly[1].speed).isEqualTo(3.0) + assertThat(hourly[2].speed).isEqualTo(0.0) + } + + @Test + fun `splitSegment enforces a minimum one-hour span when end is not after start`() { + // start 10h, end 10h -> end coerced to start+1 = 11h. Covers only hour 10. + val hourly = listOf(seg(600, 600, 5.0)).splitSegment() + assertThat(hourly[9].speed).isEqualTo(0.0) + assertThat(hourly[10].speed).isEqualTo(5.0) + assertThat(hourly[11].speed).isEqualTo(0.0) + } + + // ---------- generateUUID ---------- + + @Test + fun `generateUUID returns a canonical 36-char UUID string`() { + val uuid = generateUUID() + assertThat(uuid).hasLength(36) + // Parsing back succeeds and re-stringifies to the same value. + assertThat(UUID.fromString(uuid).toString()).isEqualTo(uuid) + } + + @Test + fun `generateUUID returns distinct values on successive calls`() { + assertThat(generateUUID()).isNotEqualTo(generateUUID()) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoBtModelTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoBtModelTest.kt new file mode 100644 index 000000000000..727bb585e43c --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoBtModelTest.kt @@ -0,0 +1,671 @@ +package app.aaps.pump.carelevo.domain.model.bt + +import app.aaps.pump.carelevo.domain.model.basal.CarelevoBasalSegment +import app.aaps.pump.carelevo.domain.model.bt.AlertMessageResult.Companion.codeToAlertMessageCommand +import app.aaps.pump.carelevo.domain.model.bt.AlertMessageResult.Companion.commandToCode as alertToCode +import app.aaps.pump.carelevo.domain.model.bt.InfusionInfoResult.Companion.codeToInfusionInfoCommand +import app.aaps.pump.carelevo.domain.model.bt.InfusionInfoResult.Companion.commandToCode as infusionInfoToCode +import app.aaps.pump.carelevo.domain.model.bt.InfusionModeResult.Companion.codeToInfusionModeCommand +import app.aaps.pump.carelevo.domain.model.bt.InfusionModeResult.Companion.commandToCode as infusionModeToCode +import app.aaps.pump.carelevo.domain.model.bt.NoticeMessageResult.Companion.codeToNoticeMessageCommand +import app.aaps.pump.carelevo.domain.model.bt.NoticeMessageResult.Companion.commandToCode as noticeToCode +import app.aaps.pump.carelevo.domain.model.bt.PumpStateResult.Companion.codeToPumpStateCommand +import app.aaps.pump.carelevo.domain.model.bt.PumpStateResult.Companion.commandToCode as pumpStateToCode +import app.aaps.pump.carelevo.domain.model.bt.Result.Companion.codeToResultCommand +import app.aaps.pump.carelevo.domain.model.bt.Result.Companion.commandToCode as resultToCode +import app.aaps.pump.carelevo.domain.model.bt.SafetyCheckResult.Companion.codeToSafetyCheckCommand +import app.aaps.pump.carelevo.domain.model.bt.SafetyCheckResult.Companion.commandToCode as safetyToCode +import app.aaps.pump.carelevo.domain.model.bt.SetBasalProgramResult.Companion.codeToSetBasalProgramCommand +import app.aaps.pump.carelevo.domain.model.bt.SetBasalProgramResult.Companion.commandToCode as setBasalToCode +import app.aaps.pump.carelevo.domain.model.bt.SetBolusProgramResult.Companion.codeToSetBolusProgramCommand +import app.aaps.pump.carelevo.domain.model.bt.SetBolusProgramResult.Companion.commandToCode as setBolusToCode +import app.aaps.pump.carelevo.domain.model.bt.StopPumpResult.Companion.codeToStopPumpCommand +import app.aaps.pump.carelevo.domain.model.bt.StopPumpResult.Companion.commandToCode as stopPumpToCode +import app.aaps.pump.carelevo.domain.model.bt.WarningMessageResult.Companion.codeToWarningMessageCommand +import app.aaps.pump.carelevo.domain.model.bt.WarningMessageResult.Companion.commandToCode as warningToCode +import app.aaps.pump.carelevo.domain.type.AlarmCause +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * Pure-logic unit tests for the Carelevo BT domain layer: + * - every enum <-> wire-code mapping in [CarelevoBtEnums] (both directions incl. the `else` default), + * - protocol classification in [isPatchProtocol] / [isBasalProtocol] / [isBolusProtocol], + * - the response -> result-model dispatch in [createPatchResultModel] / [createBasalResultModel] / + * [createBolusResultModel] (every branch + the null fall-through), + * - data-class construction for the request DTOs. + */ +internal class CarelevoBtModelTest { + + private val ts = 1_000L + private val patchCmd = 0x11 // isPatchProtocol == true + private val basalCmd = 0x13 // isBasalProtocol == true + private val bolusCmd = 0x24 // isBolusProtocol == true + + // --------------------------------------------------------------------------------------------- + // Result + // --------------------------------------------------------------------------------------------- + + @Test fun `Result commandToCode maps every value`() { + assertThat(Result.SUCCESS.resultToCode()).isEqualTo(0) + assertThat(Result.FAILED.resultToCode()).isEqualTo(1) + } + + @Test fun `Result codeToResultCommand maps codes and default`() { + assertThat(0.codeToResultCommand()).isEqualTo(Result.SUCCESS) + assertThat(1.codeToResultCommand()).isEqualTo(Result.FAILED) + assertThat(99.codeToResultCommand()).isEqualTo(Result.FAILED) + assertThat((-5).codeToResultCommand()).isEqualTo(Result.FAILED) + } + + // --------------------------------------------------------------------------------------------- + // SafetyCheckResult + // --------------------------------------------------------------------------------------------- + + @Test fun `SafetyCheckResult commandToCode maps every value`() { + assertThat(SafetyCheckResult.SUCCESS.safetyToCode()).isEqualTo(0) + assertThat(SafetyCheckResult.INSULIN_DEFICIENCY.safetyToCode()).isEqualTo(1) + assertThat(SafetyCheckResult.EXPIRED.safetyToCode()).isEqualTo(2) + assertThat(SafetyCheckResult.LOW_VOLTAGE.safetyToCode()).isEqualTo(3) + assertThat(SafetyCheckResult.PATCH_ERROR.safetyToCode()).isEqualTo(11) + assertThat(SafetyCheckResult.PUMP_ERROR.safetyToCode()).isEqualTo(12) + assertThat(SafetyCheckResult.REP_REQUEST.safetyToCode()).isEqualTo(4) + assertThat(SafetyCheckResult.REP_REQUEST1.safetyToCode()).isEqualTo(18) + assertThat(SafetyCheckResult.FAILED.safetyToCode()).isEqualTo(-1) + } + + @Test fun `SafetyCheckResult codeToSafetyCheckCommand maps codes and default`() { + assertThat(0.codeToSafetyCheckCommand()).isEqualTo(SafetyCheckResult.SUCCESS) + assertThat(1.codeToSafetyCheckCommand()).isEqualTo(SafetyCheckResult.INSULIN_DEFICIENCY) + assertThat(2.codeToSafetyCheckCommand()).isEqualTo(SafetyCheckResult.EXPIRED) + assertThat(3.codeToSafetyCheckCommand()).isEqualTo(SafetyCheckResult.LOW_VOLTAGE) + assertThat(11.codeToSafetyCheckCommand()).isEqualTo(SafetyCheckResult.PATCH_ERROR) + assertThat(12.codeToSafetyCheckCommand()).isEqualTo(SafetyCheckResult.PUMP_ERROR) + assertThat(4.codeToSafetyCheckCommand()).isEqualTo(SafetyCheckResult.REP_REQUEST) + assertThat(18.codeToSafetyCheckCommand()).isEqualTo(SafetyCheckResult.REP_REQUEST1) + assertThat(99.codeToSafetyCheckCommand()).isEqualTo(SafetyCheckResult.FAILED) + } + + // --------------------------------------------------------------------------------------------- + // SetBasalProgramResult + // --------------------------------------------------------------------------------------------- + + @Test fun `SetBasalProgramResult commandToCode maps every value`() { + assertThat(SetBasalProgramResult.SUCCESS.setBasalToCode()).isEqualTo(0) + assertThat(SetBasalProgramResult.INSULIN_DEFICIENCY.setBasalToCode()).isEqualTo(1) + assertThat(SetBasalProgramResult.EXPIRED.setBasalToCode()).isEqualTo(2) + assertThat(SetBasalProgramResult.LOW_VOLTAGE.setBasalToCode()).isEqualTo(3) + assertThat(SetBasalProgramResult.ABNORMAL_TEMP.setBasalToCode()).isEqualTo(4) + assertThat(SetBasalProgramResult.PUMP_ERROR.setBasalToCode()).isEqualTo(12) + assertThat(SetBasalProgramResult.ABNORMAL_PROGRAM.setBasalToCode()).isEqualTo(19) + assertThat(SetBasalProgramResult.EXCEED_LIMIT.setBasalToCode()).isEqualTo(20) + assertThat(SetBasalProgramResult.FAILED.setBasalToCode()).isEqualTo(-1) + } + + @Test fun `SetBasalProgramResult codeToSetBasalProgramCommand maps codes and default`() { + assertThat(0.codeToSetBasalProgramCommand()).isEqualTo(SetBasalProgramResult.SUCCESS) + assertThat(1.codeToSetBasalProgramCommand()).isEqualTo(SetBasalProgramResult.INSULIN_DEFICIENCY) + assertThat(2.codeToSetBasalProgramCommand()).isEqualTo(SetBasalProgramResult.EXPIRED) + assertThat(3.codeToSetBasalProgramCommand()).isEqualTo(SetBasalProgramResult.LOW_VOLTAGE) + assertThat(4.codeToSetBasalProgramCommand()).isEqualTo(SetBasalProgramResult.ABNORMAL_TEMP) + assertThat(12.codeToSetBasalProgramCommand()).isEqualTo(SetBasalProgramResult.PUMP_ERROR) + assertThat(19.codeToSetBasalProgramCommand()).isEqualTo(SetBasalProgramResult.ABNORMAL_PROGRAM) + assertThat(20.codeToSetBasalProgramCommand()).isEqualTo(SetBasalProgramResult.EXCEED_LIMIT) + assertThat(77.codeToSetBasalProgramCommand()).isEqualTo(SetBasalProgramResult.FAILED) + } + + // --------------------------------------------------------------------------------------------- + // SetBolusProgramResult + // --------------------------------------------------------------------------------------------- + + @Test fun `SetBolusProgramResult commandToCode maps every value`() { + assertThat(SetBolusProgramResult.SUCCESS.setBolusToCode()).isEqualTo(0) + assertThat(SetBolusProgramResult.INSULIN_DEFICIENCY.setBolusToCode()).isEqualTo(1) + assertThat(SetBolusProgramResult.EXPIRED.setBolusToCode()).isEqualTo(2) + assertThat(SetBolusProgramResult.LOW_VOLTAGE.setBolusToCode()).isEqualTo(3) + assertThat(SetBolusProgramResult.ABNORMAL_TEMP.setBolusToCode()).isEqualTo(4) + assertThat(SetBolusProgramResult.PUMP_ERROR.setBolusToCode()).isEqualTo(12) + assertThat(SetBolusProgramResult.EXCEED_LIMIT.setBolusToCode()).isEqualTo(20) + assertThat(SetBolusProgramResult.FAILED.setBolusToCode()).isEqualTo(-1) + } + + @Test fun `SetBolusProgramResult codeToSetBolusProgramCommand maps codes and default`() { + assertThat(0.codeToSetBolusProgramCommand()).isEqualTo(SetBolusProgramResult.SUCCESS) + assertThat(1.codeToSetBolusProgramCommand()).isEqualTo(SetBolusProgramResult.INSULIN_DEFICIENCY) + assertThat(2.codeToSetBolusProgramCommand()).isEqualTo(SetBolusProgramResult.EXPIRED) + assertThat(3.codeToSetBolusProgramCommand()).isEqualTo(SetBolusProgramResult.LOW_VOLTAGE) + assertThat(4.codeToSetBolusProgramCommand()).isEqualTo(SetBolusProgramResult.ABNORMAL_TEMP) + assertThat(12.codeToSetBolusProgramCommand()).isEqualTo(SetBolusProgramResult.PUMP_ERROR) + assertThat(20.codeToSetBolusProgramCommand()).isEqualTo(SetBolusProgramResult.EXCEED_LIMIT) + assertThat(50.codeToSetBolusProgramCommand()).isEqualTo(SetBolusProgramResult.FAILED) + } + + // --------------------------------------------------------------------------------------------- + // StopPumpResult + // --------------------------------------------------------------------------------------------- + + @Test fun `StopPumpResult commandToCode maps every value`() { + assertThat(StopPumpResult.BY_REQ.stopPumpToCode()).isEqualTo(0) + assertThat(StopPumpResult.INSULIN_DEFICIENCY.stopPumpToCode()).isEqualTo(1) + assertThat(StopPumpResult.ABNORMAL_PUMP.stopPumpToCode()).isEqualTo(2) + assertThat(StopPumpResult.LOW_VOLTAGE.stopPumpToCode()).isEqualTo(3) + assertThat(StopPumpResult.ABNORMAL_TEMP.stopPumpToCode()).isEqualTo(4) + assertThat(StopPumpResult.NOT_USED.stopPumpToCode()).isEqualTo(5) + assertThat(StopPumpResult.PUMP_ERROR.stopPumpToCode()).isEqualTo(12) + assertThat(StopPumpResult.BY_LGS.stopPumpToCode()).isEqualTo(29) + assertThat(StopPumpResult.ERROR.stopPumpToCode()).isEqualTo(-1) + } + + @Test fun `StopPumpResult codeToStopPumpCommand maps codes and default`() { + assertThat(0.codeToStopPumpCommand()).isEqualTo(StopPumpResult.BY_REQ) + assertThat(1.codeToStopPumpCommand()).isEqualTo(StopPumpResult.INSULIN_DEFICIENCY) + assertThat(2.codeToStopPumpCommand()).isEqualTo(StopPumpResult.ABNORMAL_PUMP) + assertThat(3.codeToStopPumpCommand()).isEqualTo(StopPumpResult.LOW_VOLTAGE) + assertThat(4.codeToStopPumpCommand()).isEqualTo(StopPumpResult.ABNORMAL_TEMP) + assertThat(5.codeToStopPumpCommand()).isEqualTo(StopPumpResult.NOT_USED) + assertThat(12.codeToStopPumpCommand()).isEqualTo(StopPumpResult.PUMP_ERROR) + assertThat(29.codeToStopPumpCommand()).isEqualTo(StopPumpResult.BY_LGS) + assertThat(99.codeToStopPumpCommand()).isEqualTo(StopPumpResult.ERROR) + } + + // --------------------------------------------------------------------------------------------- + // InfusionModeResult + // --------------------------------------------------------------------------------------------- + + @Test fun `InfusionModeResult commandToCode maps every value`() { + assertThat(InfusionModeResult.BASAL.infusionModeToCode()).isEqualTo(1) + assertThat(InfusionModeResult.TEMP_BASAL.infusionModeToCode()).isEqualTo(2) + assertThat(InfusionModeResult.IMME_BOLUS.infusionModeToCode()).isEqualTo(3) + assertThat(InfusionModeResult.EXTEND_IMME_BOLUS.infusionModeToCode()).isEqualTo(4) + assertThat(InfusionModeResult.EXTEND_BOLUS.infusionModeToCode()).isEqualTo(5) + assertThat(InfusionModeResult.ERROR.infusionModeToCode()).isEqualTo(-1) + } + + @Test fun `InfusionModeResult codeToInfusionModeCommand maps codes and default`() { + assertThat(1.codeToInfusionModeCommand()).isEqualTo(InfusionModeResult.BASAL) + assertThat(2.codeToInfusionModeCommand()).isEqualTo(InfusionModeResult.TEMP_BASAL) + assertThat(3.codeToInfusionModeCommand()).isEqualTo(InfusionModeResult.IMME_BOLUS) + assertThat(4.codeToInfusionModeCommand()).isEqualTo(InfusionModeResult.EXTEND_IMME_BOLUS) + assertThat(5.codeToInfusionModeCommand()).isEqualTo(InfusionModeResult.EXTEND_BOLUS) + assertThat(0.codeToInfusionModeCommand()).isEqualTo(InfusionModeResult.ERROR) + assertThat(99.codeToInfusionModeCommand()).isEqualTo(InfusionModeResult.ERROR) + } + + // --------------------------------------------------------------------------------------------- + // InfusionInfoResult + // --------------------------------------------------------------------------------------------- + + @Test fun `InfusionInfoResult commandToCode maps every value`() { + assertThat(InfusionInfoResult.BY_REQ.infusionInfoToCode()).isEqualTo(0) + assertThat(InfusionInfoResult.BY_REMAIN_REQ.infusionInfoToCode()).isEqualTo(1) + assertThat(InfusionInfoResult.BY_30MIN_RPT.infusionInfoToCode()).isEqualTo(2) + assertThat(InfusionInfoResult.BY_RECONNECT.infusionInfoToCode()).isEqualTo(3) + assertThat(InfusionInfoResult.ERROR.infusionInfoToCode()).isEqualTo(-1) + } + + @Test fun `InfusionInfoResult codeToInfusionInfoCommand maps codes and default`() { + assertThat(0.codeToInfusionInfoCommand()).isEqualTo(InfusionInfoResult.BY_REQ) + assertThat(1.codeToInfusionInfoCommand()).isEqualTo(InfusionInfoResult.BY_REMAIN_REQ) + assertThat(2.codeToInfusionInfoCommand()).isEqualTo(InfusionInfoResult.BY_30MIN_RPT) + assertThat(3.codeToInfusionInfoCommand()).isEqualTo(InfusionInfoResult.BY_RECONNECT) + assertThat(42.codeToInfusionInfoCommand()).isEqualTo(InfusionInfoResult.ERROR) + } + + // --------------------------------------------------------------------------------------------- + // PumpStateResult (decode receiver is nullable Int?) + // --------------------------------------------------------------------------------------------- + + @Test fun `PumpStateResult commandToCode maps every value`() { + assertThat(PumpStateResult.READY.pumpStateToCode()).isEqualTo(0) + assertThat(PumpStateResult.PRIMING.pumpStateToCode()).isEqualTo(1) + assertThat(PumpStateResult.RUNNING.pumpStateToCode()).isEqualTo(2) + assertThat(PumpStateResult.ERROR.pumpStateToCode()).isEqualTo(3) + } + + @Test fun `PumpStateResult codeToPumpStateCommand maps codes default and null`() { + assertThat(0.codeToPumpStateCommand()).isEqualTo(PumpStateResult.READY) + assertThat(1.codeToPumpStateCommand()).isEqualTo(PumpStateResult.PRIMING) + assertThat(2.codeToPumpStateCommand()).isEqualTo(PumpStateResult.RUNNING) + assertThat(3.codeToPumpStateCommand()).isEqualTo(PumpStateResult.ERROR) + assertThat(99.codeToPumpStateCommand()).isEqualTo(PumpStateResult.ERROR) + val nullCode: Int? = null + assertThat(nullCode.codeToPumpStateCommand()).isEqualTo(PumpStateResult.ERROR) + } + + // --------------------------------------------------------------------------------------------- + // WarningMessageResult + // --------------------------------------------------------------------------------------------- + + @Test fun `WarningMessageResult commandToCode maps every value`() { + assertThat(WarningMessageResult.INSULIN_DEFICIENCY.warningToCode()).isEqualTo(1) + assertThat(WarningMessageResult.EXPIRED.warningToCode()).isEqualTo(2) + assertThat(WarningMessageResult.LOW_VOLTAGE.warningToCode()).isEqualTo(3) + assertThat(WarningMessageResult.ABNORMAL_TEMP.warningToCode()).isEqualTo(4) + assertThat(WarningMessageResult.NOT_USED.warningToCode()).isEqualTo(5) + assertThat(WarningMessageResult.BLE_CONNECT.warningToCode()).isEqualTo(6) + assertThat(WarningMessageResult.NOT_STARTED_BASAL.warningToCode()).isEqualTo(7) + assertThat(WarningMessageResult.EXTENDED_EXPIRED.warningToCode()).isEqualTo(10) + assertThat(WarningMessageResult.PUMP_ERROR.warningToCode()).isEqualTo(12) + assertThat(WarningMessageResult.CANNULA_ERROR.warningToCode()).isEqualTo(99) + assertThat(WarningMessageResult.ERROR.warningToCode()).isEqualTo(-1) + } + + @Test fun `WarningMessageResult codeToWarningMessageCommand maps codes and default`() { + assertThat(1.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.INSULIN_DEFICIENCY) + assertThat(2.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.EXPIRED) + assertThat(3.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.LOW_VOLTAGE) + assertThat(4.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.ABNORMAL_TEMP) + assertThat(5.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.NOT_USED) + assertThat(6.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.BLE_CONNECT) + assertThat(7.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.NOT_STARTED_BASAL) + assertThat(10.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.EXTENDED_EXPIRED) + assertThat(12.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.PUMP_ERROR) + assertThat(99.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.CANNULA_ERROR) + assertThat(0.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.ERROR) + } + + // --------------------------------------------------------------------------------------------- + // AlertMessageResult + // --------------------------------------------------------------------------------------------- + + @Test fun `AlertMessageResult commandToCode maps every value`() { + assertThat(AlertMessageResult.INSULIN_LOW.alertToCode()).isEqualTo(1) + assertThat(AlertMessageResult.EXPIRED_ALERT.alertToCode()).isEqualTo(2) + assertThat(AlertMessageResult.BATTERY_EXCEED.alertToCode()).isEqualTo(3) + assertThat(AlertMessageResult.ABNORMAL_TEMP.alertToCode()).isEqualTo(4) + assertThat(AlertMessageResult.NOT_USED.alertToCode()).isEqualTo(5) + assertThat(AlertMessageResult.BLE_CONNECT.alertToCode()).isEqualTo(6) + assertThat(AlertMessageResult.NOT_START_BASAL.alertToCode()).isEqualTo(7) + assertThat(AlertMessageResult.PUMP_STOP_FINISH.alertToCode()).isEqualTo(8) + assertThat(AlertMessageResult.EXTEND_EXPIRED.alertToCode()).isEqualTo(10) + assertThat(AlertMessageResult.ERROR.alertToCode()).isEqualTo(-1) + } + + @Test fun `AlertMessageResult codeToAlertMessageCommand maps codes and default`() { + assertThat(1.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.INSULIN_LOW) + assertThat(2.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.EXPIRED_ALERT) + assertThat(3.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.BATTERY_EXCEED) + assertThat(4.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.ABNORMAL_TEMP) + assertThat(5.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.NOT_USED) + assertThat(6.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.BLE_CONNECT) + assertThat(7.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.NOT_START_BASAL) + assertThat(8.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.PUMP_STOP_FINISH) + assertThat(10.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.EXTEND_EXPIRED) + assertThat(0.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.ERROR) + } + + // --------------------------------------------------------------------------------------------- + // NoticeMessageResult + // --------------------------------------------------------------------------------------------- + + @Test fun `NoticeMessageResult commandToCode maps every value`() { + assertThat(NoticeMessageResult.REMAIN_EXCEED.noticeToCode()).isEqualTo(1) + assertThat(NoticeMessageResult.EXPIRED_NOTICE.noticeToCode()).isEqualTo(2) + assertThat(NoticeMessageResult.INSPECTING.noticeToCode()).isEqualTo(3) + assertThat(NoticeMessageResult.SYNC_TIME.noticeToCode()).isEqualTo(26) + assertThat(NoticeMessageResult.GLUCOSE.noticeToCode()).isEqualTo(27) + assertThat(NoticeMessageResult.ERROR.noticeToCode()).isEqualTo(-1) + } + + @Test fun `NoticeMessageResult codeToNoticeMessageCommand maps codes and default`() { + assertThat(1.codeToNoticeMessageCommand()).isEqualTo(NoticeMessageResult.REMAIN_EXCEED) + assertThat(2.codeToNoticeMessageCommand()).isEqualTo(NoticeMessageResult.EXPIRED_NOTICE) + assertThat(3.codeToNoticeMessageCommand()).isEqualTo(NoticeMessageResult.INSPECTING) + assertThat(26.codeToNoticeMessageCommand()).isEqualTo(NoticeMessageResult.SYNC_TIME) + assertThat(27.codeToNoticeMessageCommand()).isEqualTo(NoticeMessageResult.GLUCOSE) + assertThat(0.codeToNoticeMessageCommand()).isEqualTo(NoticeMessageResult.ERROR) + } + + // --------------------------------------------------------------------------------------------- + // Protocol classification + // --------------------------------------------------------------------------------------------- + + @Test fun `isPatchProtocol true false and default branches`() { + // representative "patch == true" opcodes + assertThat(isPatchProtocol(0x11)).isTrue() + assertThat(isPatchProtocol(0x26)).isTrue() + assertThat(isPatchProtocol(0x31)).isTrue() + assertThat(isPatchProtocol(0x4A)).isTrue() + assertThat(isPatchProtocol(0xBB)).isTrue() + // explicitly listed "false" opcodes + assertThat(isPatchProtocol(0x13)).isFalse() + assertThat(isPatchProtocol(0x21)).isFalse() + assertThat(isPatchProtocol(0x9C)).isFalse() + // unlisted -> else -> false + assertThat(isPatchProtocol(0x00)).isFalse() + assertThat(isPatchProtocol(0xFF)).isFalse() + } + + @Test fun `isBasalProtocol true false and default branches`() { + assertThat(isBasalProtocol(0x13)).isTrue() + assertThat(isBasalProtocol(0x21)).isTrue() + assertThat(isBasalProtocol(0x88)).isTrue() + assertThat(isBasalProtocol(0x2D)).isTrue() + assertThat(isBasalProtocol(0x11)).isFalse() + assertThat(isBasalProtocol(0x24)).isFalse() + assertThat(isBasalProtocol(0x00)).isFalse() + } + + @Test fun `isBolusProtocol true false and default branches`() { + assertThat(isBolusProtocol(0x24)).isTrue() + assertThat(isBolusProtocol(0x29)).isTrue() + assertThat(isBolusProtocol(0x2C)).isTrue() + assertThat(isBolusProtocol(0x85)).isTrue() + assertThat(isBolusProtocol(0x11)).isFalse() + assertThat(isBolusProtocol(0x13)).isFalse() + assertThat(isBolusProtocol(0x00)).isFalse() + } + + // --------------------------------------------------------------------------------------------- + // createPatchResultModel — one assertion per response branch + // --------------------------------------------------------------------------------------------- + + @Test fun `patch SetTimeResponse maps to SetTimeResultModel`() { + assertThat(createPatchResultModel(SetTimeResponse(ts, patchCmd, 1))) + .isEqualTo(SetTimeResultModel(Result.FAILED)) + } + + @Test fun `patch PatchInformationInquiryResponse maps model`() { + assertThat(createPatchResultModel(PatchInformationInquiryResponse(ts, patchCmd, 0, "SN123"))) + .isEqualTo(PatchInformationInquiryModel(Result.SUCCESS, "SN123")) + } + + @Test fun `patch PatchInformationInquiryDetailResponse maps model`() { + assertThat(createPatchResultModel(PatchInformationInquiryDetailResponse(ts, patchCmd, 0, "1.2.3", "2024-01-01", "CL-100"))) + .isEqualTo(PatchInformationInquiryDetailModel(Result.SUCCESS, "1.2.3", "2024-01-01", "CL-100")) + } + + @Test fun `patch SafetyCheckResponse maps model`() { + assertThat(createPatchResultModel(SafetyCheckResponse(ts, patchCmd, 1, 120, 30))) + .isEqualTo(SafetyCheckResultModel(SafetyCheckResult.INSULIN_DEFICIENCY, 120, 30)) + } + + @Test fun `patch ThresholdSetResponse maps model`() { + assertThat(createPatchResultModel(ThresholdSetResponse(ts, patchCmd, 0))) + .isEqualTo(ThresholdSetResultModel(Result.SUCCESS)) + } + + @Test fun `patch CannulaInsertionResponse maps model`() { + assertThat(createPatchResultModel(CannulaInsertionResponse(ts, patchCmd, 0))) + .isEqualTo(CannulaInsertionResultModel(Result.SUCCESS)) + } + + @Test fun `patch CannulaInsertionAckResponse maps model`() { + assertThat(createPatchResultModel(CannulaInsertionAckResponse(ts, patchCmd, 0))) + .isEqualTo(CannulaInsertionAckResultModel(Result.SUCCESS)) + } + + @Test fun `patch SetInfusionThresholdResponse maps model`() { + assertThat(createPatchResultModel(SetInfusionThresholdResponse(ts, patchCmd, 2, 0))) + .isEqualTo(SetInfusionThresholdResultModel(Result.SUCCESS, 2)) + } + + @Test fun `patch SetBuzzModeResponse maps model`() { + assertThat(createPatchResultModel(SetBuzzModeResponse(ts, patchCmd, 0))) + .isEqualTo(SetBuzzModeResultModel(Result.SUCCESS)) + } + + @Test fun `patch ClearReportResponse maps to SetAlarmClearResultModel`() { + assertThat(createPatchResultModel(ClearReportResponse(ts, patchCmd, 0, 3, 4))) + .isEqualTo(SetAlarmClearResultModel(Result.SUCCESS, 3, 4)) + } + + @Test fun `patch SetExpiryExtendResponse maps to ExtendPatchExpiryResultModel`() { + assertThat(createPatchResultModel(SetExpiryExtendResponse(ts, patchCmd, 0))) + .isEqualTo(ExtendPatchExpiryResultModel(Result.SUCCESS)) + } + + @Test fun `patch StopPumpResponse maps model`() { + assertThat(createPatchResultModel(StopPumpResponse(ts, patchCmd, 0))) + .isEqualTo(StopPumpResultModel(Result.SUCCESS)) + } + + @Test fun `patch ResumePumpResponse maps model`() { + assertThat(createPatchResultModel(ResumePumpResponse(ts, patchCmd, 0, 1, 7))) + .isEqualTo(ResumePumpResultModel(StopPumpResult.BY_REQ, InfusionModeResult.BASAL, 7)) + } + + @Test fun `patch StopPumpReportResponse maps model`() { + assertThat(createPatchResultModel(StopPumpReportResponse(ts, patchCmd, 1, 3, 8, 1.5, 0.5, 25))) + .isEqualTo(StopPumpReportResultModel(StopPumpResult.INSULIN_DEFICIENCY, InfusionModeResult.IMME_BOLUS, 8, 1.5, 0.5, 25)) + } + + @Test fun `patch RetrieveInfusionStatusResponse maps model`() { + assertThat( + createPatchResultModel( + RetrieveInfusionStatusResponse(ts, patchCmd, 0, 30, 100.0, 1.0, 2.0, 2, 1, 60, 5.0, 45) + ) + ).isEqualTo( + InfusionInfoReportResultModel( + InfusionInfoResult.BY_REQ, 30, 100.0, 1.0, 2.0, PumpStateResult.RUNNING, InfusionModeResult.BASAL, 60, 5.0, 45 + ) + ) + } + + @Test fun `patch SetApplicationStatusResponse maps model`() { + assertThat(createPatchResultModel(SetApplicationStatusResponse(ts, patchCmd, 1))) + .isEqualTo(SetApplicationStatusResultModel(1)) + } + + @Test fun `patch RetrieveAddressResponse maps model`() { + assertThat(createPatchResultModel(RetrieveAddressResponse(ts, patchCmd, "AA:BB:CC", "3F"))) + .isEqualTo(RetrieveAddressResultModel("AA:BB:CC", "3F")) + } + + @Test fun `patch SetDiscardResponse maps to DiscardPatchResultModel`() { + assertThat(createPatchResultModel(SetDiscardResponse(ts, patchCmd, 0))) + .isEqualTo(DiscardPatchResultModel(Result.SUCCESS)) + } + + @Test fun `patch RecoveryPatchResponse maps to RecoveryPatchReportResultModel`() { + assertThat(createPatchResultModel(RecoveryPatchResponse(ts, patchCmd))) + .isInstanceOf(RecoveryPatchReportResultModel::class.java) + } + + @Test fun `patch WarningReportResponse resolves AlarmCause`() { + assertThat(createPatchResultModel(WarningReportResponse(ts, patchCmd, 0x01, 50))) + .isEqualTo(WarningReportResultModel(AlarmCause.ALARM_WARNING_LOW_INSULIN, 50)) + } + + @Test fun `patch WarningReportResponse unknown cause maps to ALARM_UNKNOWN`() { + assertThat(createPatchResultModel(WarningReportResponse(ts, patchCmd, 0x7F, 0))) + .isEqualTo(WarningReportResultModel(AlarmCause.ALARM_UNKNOWN, 0)) + } + + @Test fun `patch AlertReportResponse resolves AlarmCause`() { + assertThat(createPatchResultModel(AlertReportResponse(ts, patchCmd, 0x01, 60))) + .isEqualTo(AlertReportResultModel(AlarmCause.ALARM_ALERT_OUT_OF_INSULIN, 60)) + } + + @Test fun `patch NoticeReportResponse resolves AlarmCause`() { + assertThat(createPatchResultModel(NoticeReportResponse(ts, patchCmd, 0x01, 70))) + .isEqualTo(NoticeReportResultModel(AlarmCause.ALARM_NOTICE_LOW_INSULIN, 70)) + } + + @Test fun `patch AppAuthRptResponse maps model`() { + assertThat(createPatchResultModel(AppAuthRptResponse(ts, patchCmd, 5))) + .isEqualTo(AppAuthAckReportResultModel(5)) + } + + @Test fun `patch AdditionalPrimingResponse maps model`() { + assertThat(createPatchResultModel(AdditionalPrimingResponse(ts, patchCmd, 0))) + .isEqualTo(AdditionalPrimingResultModel(Result.SUCCESS)) + } + + @Test fun `patch SetThresholdNoticeResponse maps model`() { + assertThat(createPatchResultModel(SetThresholdNoticeResponse(ts, patchCmd, 0, 1))) + .isEqualTo(SetThresholdNoticeResultModel(1, Result.SUCCESS)) + } + + @Test fun `patch SetAlertAlarmModelResponse maps to AlertAlarmSetResultModel`() { + assertThat(createPatchResultModel(SetAlertAlarmModelResponse(ts, patchCmd, 0))) + .isEqualTo(AlertAlarmSetResultModel(Result.SUCCESS)) + } + + @Test fun `patch AppAuthAckRptResponse maps to AppAuthAckResultModel`() { + assertThat(createPatchResultModel(AppAuthAckRptResponse(ts, patchCmd, 0))) + .isEqualTo(AppAuthAckResultModel(Result.SUCCESS)) + } + + @Test fun `patch AppAlarmOffResponse maps to AppAlarmClearResultModel`() { + assertThat(createPatchResultModel(AppAlarmOffResponse(ts, patchCmd, 0))) + .isEqualTo(AppAlarmClearResultModel(Result.SUCCESS)) + } + + @Test fun `patch RetrieveOperationInfoResponse maps model`() { + assertThat(createPatchResultModel(RetrieveOperationInfoResponse(ts, patchCmd, 2, 10, 100, 5, 30, 50.0))) + .isEqualTo(RetrieveOperationInfoResultModel(2, 10, 100, 5, 30, 50.0)) + } + + @Test fun `patch CheckBuzzResponse maps to AppBuzzResultModel`() { + assertThat(createPatchResultModel(CheckBuzzResponse(ts, patchCmd, 0))) + .isEqualTo(AppBuzzResultModel(Result.SUCCESS)) + } + + @Test fun `patch unrecognised response type returns null`() { + assertThat(createPatchResultModel(SetInitializeResponse(ts, patchCmd, 1))).isNull() + } + + @Test fun `patch non-patch command returns null even for known type`() { + assertThat(createPatchResultModel(SetTimeResponse(ts, basalCmd, 0))).isNull() + } + + // --------------------------------------------------------------------------------------------- + // createBasalResultModel + // --------------------------------------------------------------------------------------------- + + @Test fun `basal SetBasalProgramResponse maps model`() { + assertThat(createBasalResultModel(SetBasalProgramResponse(ts, basalCmd, 0))) + .isEqualTo(SetBasalProgramResultModel(SetBasalProgramResult.SUCCESS)) + } + + @Test fun `basal SetBasalProgramAdditionalResponse maps model`() { + assertThat(createBasalResultModel(SetBasalProgramAdditionalResponse(ts, basalCmd, 1))) + .isEqualTo(SetBasalProgramAdditionalResultModel(SetBasalProgramResult.INSULIN_DEFICIENCY)) + } + + @Test fun `basal UpdateBasalProgramResponse maps model`() { + assertThat(createBasalResultModel(UpdateBasalProgramResponse(ts, basalCmd, 0))) + .isEqualTo(UpdateBasalProgramResultModel(SetBasalProgramResult.SUCCESS)) + } + + @Test fun `basal UpdateBasalProgramAdditionalResponse maps model`() { + assertThat(createBasalResultModel(UpdateBasalProgramAdditionalResponse(ts, basalCmd, 0))) + .isEqualTo(UpdateBasalProgramAdditionalResultModel(SetBasalProgramResult.SUCCESS)) + } + + @Test fun `basal StartTempBasalProgramResponse maps model`() { + assertThat(createBasalResultModel(StartTempBasalProgramResponse(ts, basalCmd, 20))) + .isEqualTo(StartTempBasalProgramResultModel(SetBasalProgramResult.EXCEED_LIMIT)) + } + + @Test fun `basal CancelTempBasalProgramResponse maps model`() { + assertThat(createBasalResultModel(CancelTempBasalProgramResponse(ts, basalCmd, 0))) + .isEqualTo(CancelTempBasalProgramResultModel(Result.SUCCESS)) + } + + @Test fun `basal StartBasalProgramResponse maps to StartBasalProgramResultModel`() { + assertThat(createBasalResultModel(StartBasalProgramResponse(ts, basalCmd))) + .isInstanceOf(StartBasalProgramResultModel::class.java) + } + + @Test fun `basal ResumeBasalProgramResponse maps model`() { + assertThat(createBasalResultModel(ResumeBasalProgramResponse(ts, basalCmd, 1, 2.5, 30, 100.0))) + .isEqualTo(BasalInfusionResumeResultModel(1, 2.5, 30, 100.0)) + } + + @Test fun `basal unrecognised response type returns null`() { + assertThat(createBasalResultModel(SetInitializeResponse(ts, basalCmd, 1))).isNull() + } + + @Test fun `basal non-basal command returns null even for known type`() { + assertThat(createBasalResultModel(SetBasalProgramResponse(ts, patchCmd, 0))).isNull() + } + + // --------------------------------------------------------------------------------------------- + // createBolusResultModel + // --------------------------------------------------------------------------------------------- + + @Test fun `bolus StartImmeBolusResponse maps model`() { + assertThat(createBolusResultModel(StartImmeBolusResponse(ts, bolusCmd, 0, 11, 60, 99.0))) + .isEqualTo(StartImmeBolusResultModel(SetBolusProgramResult.SUCCESS, 11, 60, 99.0)) + } + + @Test fun `bolus CancelImmeBolusResponse maps model`() { + assertThat(createBolusResultModel(CancelImmeBolusResponse(ts, bolusCmd, 0, 50.0, 2.0))) + .isEqualTo(CancelImmeBolusResultModel(Result.SUCCESS, 50.0, 2.0)) + } + + @Test fun `bolus StartExtendBolusResponse maps model`() { + assertThat(createBolusResultModel(StartExtendBolusResponse(ts, bolusCmd, 0, 120))) + .isEqualTo(StartExtendBolusResultModel(SetBolusProgramResult.SUCCESS, 120)) + } + + @Test fun `bolus CancelExtendBolusResponse maps model`() { + assertThat(createBolusResultModel(CancelExtendBolusResponse(ts, bolusCmd, 0, 1.0))) + .isEqualTo(CancelExtendBolusResultModel(Result.SUCCESS, 1.0)) + } + + @Test fun `bolus DelayExtendBolusResponse maps model`() { + assertThat(createBolusResultModel(DelayExtendBolusResponse(ts, bolusCmd, 3.0, 90))) + .isEqualTo(DelayExtendBolusReportResultModel(3.0, 90)) + } + + @Test fun `bolus unrecognised response type returns null`() { + assertThat(createBolusResultModel(SetInitializeResponse(ts, bolusCmd, 1))).isNull() + } + + @Test fun `bolus non-bolus command returns null even for known type`() { + assertThat(createBolusResultModel(StartImmeBolusResponse(ts, patchCmd, 0, 1, 60, 99.0))).isNull() + } + + // --------------------------------------------------------------------------------------------- + // Result-model DTOs not produced by any create* dispatcher (constructor coverage) + // --------------------------------------------------------------------------------------------- + + @Test fun `standalone result-model DTOs construct correctly`() { + assertThat(ProtocolFailedAlarmMode(alarmId = 42L, cause = 7).cause).isEqualTo(7) + assertThat(FinishPulseReportResultModel(InfusionModeResult.EXTEND_BOLUS, 3, 10, 2, 15, 40.0).remains).isEqualTo(40.0) + assertThat(SetBasalProgramResultModel(SetBasalProgramResult.SUCCESS).result).isEqualTo(SetBasalProgramResult.SUCCESS) + assertThat(StartTempBasalProgramResultModel(SetBasalProgramResult.EXPIRED).result).isEqualTo(SetBasalProgramResult.EXPIRED) + } + + // --------------------------------------------------------------------------------------------- + // Request DTO construction + // --------------------------------------------------------------------------------------------- + + @Test fun `simple request DTOs carry their fields`() { + assertThat(SetTimeRequest("2024-01-01T00:00:00", 100, 1, 2).aidMode).isEqualTo(2) + assertThat(SetBuzzModeRequest(true).isOn).isTrue() + assertThat(ThresholdSetRequest(100, 116, 2.5, 15.0, false).buzzUse).isFalse() + assertThat(SetAlertAlarmModeRequest(3).mode).isEqualTo(3) + assertThat(SetExpiryExtendRequest(24).extendHour).isEqualTo(24) + assertThat(StopPumpRequest(30, 4).expectMinutes).isEqualTo(30) + assertThat(ResumePumpRequest(1, 7).causeId).isEqualTo(7) + assertThat(StopPumpRptAckRequest(5).subId).isEqualTo(5) + assertThat(SetThresholdInfusionMaxSpeedRequest(12.5).value).isEqualTo(12.5) + assertThat(SetThresholdNoticeRequest(50, 2).type).isEqualTo(2) + assertThat(SetThresholdInfusionMaxDoseRequest(20.0).value).isEqualTo(20.0) + assertThat(RetrieveInfusionStatusRequest(1).inquiryType).isEqualTo(1) + assertThat(SetApplicationStatusRequest(true, 6).infusionStopHour).isEqualTo(6) + assertThat(SetAlarmClearRequest(0, 9).causeId).isEqualTo(9) + assertThat(SetInitializeRequest(true).mode).isTrue() + assertThat(RetrieveAddressRequest(0x0A.toByte()).key).isEqualTo(0x0A.toByte()) + } + + @Test fun `basal request DTOs carry their segments`() { + val segments = listOf(CarelevoBasalSegment(0, 0, 1.5), CarelevoBasalSegment(1, 30, 2.0)) + assertThat(SetBasalProgramRequest(2, segments).segmentList).isEqualTo(segments) + assertThat(SetBasalProgramAdditionalRequest(1, 2, segments).msgNumber).isEqualTo(1) + assertThat(SetBasalProgramRequestV2(3, segments).seqNo).isEqualTo(3) + assertThat(UpdateBasalProgramRequest(2, segments).totalBasalSegmentCnt).isEqualTo(2) + assertThat(UpdateBasalProgramAdditionalRequest(1, 2, segments).segmentCnt).isEqualTo(2) + } + + @Test fun `temp-basal and bolus request DTOs carry their fields`() { + assertThat(StartTempBasalProgramByUnitRequest(1.5, 1, 30).infusionUnit).isEqualTo(1.5) + assertThat(StartTempBasalProgramByPercentRequest(150, 2, 0).infusionPercent).isEqualTo(150) + assertThat(StartImmeBolusRequest(7, 3.5).volume).isEqualTo(3.5) + assertThat(StartExtendBolusRequest(4.0, 1.0, 1, 30).hour).isEqualTo(1) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoBtResultTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoBtResultTest.kt new file mode 100644 index 000000000000..ab58df6d816a --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoBtResultTest.kt @@ -0,0 +1,825 @@ +package app.aaps.pump.carelevo.domain.model.bt + +import app.aaps.pump.carelevo.domain.model.bt.AlertMessageResult.Companion.codeToAlertMessageCommand +import app.aaps.pump.carelevo.domain.model.bt.AlertMessageResult.Companion.commandToCode as alertToCode +import app.aaps.pump.carelevo.domain.model.bt.InfusionInfoResult.Companion.codeToInfusionInfoCommand +import app.aaps.pump.carelevo.domain.model.bt.InfusionInfoResult.Companion.commandToCode as infusionInfoToCode +import app.aaps.pump.carelevo.domain.model.bt.InfusionModeResult.Companion.codeToInfusionModeCommand +import app.aaps.pump.carelevo.domain.model.bt.InfusionModeResult.Companion.commandToCode as infusionModeToCode +import app.aaps.pump.carelevo.domain.model.bt.NoticeMessageResult.Companion.codeToNoticeMessageCommand +import app.aaps.pump.carelevo.domain.model.bt.NoticeMessageResult.Companion.commandToCode as noticeToCode +import app.aaps.pump.carelevo.domain.model.bt.PumpStateResult.Companion.codeToPumpStateCommand +import app.aaps.pump.carelevo.domain.model.bt.PumpStateResult.Companion.commandToCode as pumpStateToCode +import app.aaps.pump.carelevo.domain.model.bt.Result.Companion.codeToResultCommand +import app.aaps.pump.carelevo.domain.model.bt.Result.Companion.commandToCode as resultToCode +import app.aaps.pump.carelevo.domain.model.bt.SafetyCheckResult.Companion.codeToSafetyCheckCommand +import app.aaps.pump.carelevo.domain.model.bt.SafetyCheckResult.Companion.commandToCode as safetyToCode +import app.aaps.pump.carelevo.domain.model.bt.SetBasalProgramResult.Companion.codeToSetBasalProgramCommand +import app.aaps.pump.carelevo.domain.model.bt.SetBasalProgramResult.Companion.commandToCode as setBasalToCode +import app.aaps.pump.carelevo.domain.model.bt.SetBolusProgramResult.Companion.codeToSetBolusProgramCommand +import app.aaps.pump.carelevo.domain.model.bt.SetBolusProgramResult.Companion.commandToCode as setBolusToCode +import app.aaps.pump.carelevo.domain.model.bt.StopPumpResult.Companion.codeToStopPumpCommand +import app.aaps.pump.carelevo.domain.model.bt.StopPumpResult.Companion.commandToCode as stopPumpToCode +import app.aaps.pump.carelevo.domain.model.bt.WarningMessageResult.Companion.codeToWarningMessageCommand +import app.aaps.pump.carelevo.domain.model.bt.WarningMessageResult.Companion.commandToCode as warningToCode +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * Pure-logic coverage for the Carelevo BT result layer: + * - the enum companion parse/encode factories in CarelevoBtEnums.kt + * - the response -> result-model dispatch factories in CarelevoBtModel.kt + * - the protocol classifiers in CarelevoProtocolChecker.kt + * Everything under test is plain, mockable JVM logic (no Android, no coroutines). + */ +internal class CarelevoBtResultTest { + + private companion object { + + const val TS = 1_000L + + // Command opcodes chosen so the dispatch guards resolve to the intended protocol family. + const val PATCH = 0x11 // isPatchProtocol == true + const val BASAL = 0x13 // isBasalProtocol == true + const val BOLUS = 0x24 // isBolusProtocol == true + } + + // region Result enum --------------------------------------------------------------------------- + + @Test fun `Result codeToResultCommand maps known codes`() { + assertThat(0.codeToResultCommand()).isEqualTo(Result.SUCCESS) + assertThat(1.codeToResultCommand()).isEqualTo(Result.FAILED) + } + + @Test fun `Result codeToResultCommand falls back to FAILED for unknown`() { + assertThat(2.codeToResultCommand()).isEqualTo(Result.FAILED) + assertThat((-1).codeToResultCommand()).isEqualTo(Result.FAILED) + } + + @Test fun `Result commandToCode encodes both members`() { + assertThat(Result.SUCCESS.resultToCode()).isEqualTo(0) + assertThat(Result.FAILED.resultToCode()).isEqualTo(1) + } + + // endregion + + // region SafetyCheckResult enum ---------------------------------------------------------------- + + @Test fun `SafetyCheckResult codeToSafetyCheckCommand maps every known code`() { + assertThat(0.codeToSafetyCheckCommand()).isEqualTo(SafetyCheckResult.SUCCESS) + assertThat(1.codeToSafetyCheckCommand()).isEqualTo(SafetyCheckResult.INSULIN_DEFICIENCY) + assertThat(2.codeToSafetyCheckCommand()).isEqualTo(SafetyCheckResult.EXPIRED) + assertThat(3.codeToSafetyCheckCommand()).isEqualTo(SafetyCheckResult.LOW_VOLTAGE) + assertThat(11.codeToSafetyCheckCommand()).isEqualTo(SafetyCheckResult.PATCH_ERROR) + assertThat(12.codeToSafetyCheckCommand()).isEqualTo(SafetyCheckResult.PUMP_ERROR) + assertThat(4.codeToSafetyCheckCommand()).isEqualTo(SafetyCheckResult.REP_REQUEST) + assertThat(18.codeToSafetyCheckCommand()).isEqualTo(SafetyCheckResult.REP_REQUEST1) + } + + @Test fun `SafetyCheckResult codeToSafetyCheckCommand unknown is FAILED`() { + assertThat(99.codeToSafetyCheckCommand()).isEqualTo(SafetyCheckResult.FAILED) + } + + @Test fun `SafetyCheckResult commandToCode encodes every member`() { + assertThat(SafetyCheckResult.SUCCESS.safetyToCode()).isEqualTo(0) + assertThat(SafetyCheckResult.INSULIN_DEFICIENCY.safetyToCode()).isEqualTo(1) + assertThat(SafetyCheckResult.EXPIRED.safetyToCode()).isEqualTo(2) + assertThat(SafetyCheckResult.LOW_VOLTAGE.safetyToCode()).isEqualTo(3) + assertThat(SafetyCheckResult.PATCH_ERROR.safetyToCode()).isEqualTo(11) + assertThat(SafetyCheckResult.PUMP_ERROR.safetyToCode()).isEqualTo(12) + assertThat(SafetyCheckResult.REP_REQUEST.safetyToCode()).isEqualTo(4) + assertThat(SafetyCheckResult.REP_REQUEST1.safetyToCode()).isEqualTo(18) + assertThat(SafetyCheckResult.FAILED.safetyToCode()).isEqualTo(-1) + } + + // endregion + + // region SetBasalProgramResult enum ------------------------------------------------------------ + + @Test fun `SetBasalProgramResult codeToSetBasalProgramCommand maps every known code`() { + assertThat(0.codeToSetBasalProgramCommand()).isEqualTo(SetBasalProgramResult.SUCCESS) + assertThat(1.codeToSetBasalProgramCommand()).isEqualTo(SetBasalProgramResult.INSULIN_DEFICIENCY) + assertThat(2.codeToSetBasalProgramCommand()).isEqualTo(SetBasalProgramResult.EXPIRED) + assertThat(3.codeToSetBasalProgramCommand()).isEqualTo(SetBasalProgramResult.LOW_VOLTAGE) + assertThat(4.codeToSetBasalProgramCommand()).isEqualTo(SetBasalProgramResult.ABNORMAL_TEMP) + assertThat(12.codeToSetBasalProgramCommand()).isEqualTo(SetBasalProgramResult.PUMP_ERROR) + assertThat(19.codeToSetBasalProgramCommand()).isEqualTo(SetBasalProgramResult.ABNORMAL_PROGRAM) + assertThat(20.codeToSetBasalProgramCommand()).isEqualTo(SetBasalProgramResult.EXCEED_LIMIT) + } + + @Test fun `SetBasalProgramResult codeToSetBasalProgramCommand unknown is FAILED`() { + assertThat(255.codeToSetBasalProgramCommand()).isEqualTo(SetBasalProgramResult.FAILED) + } + + @Test fun `SetBasalProgramResult commandToCode encodes every member`() { + assertThat(SetBasalProgramResult.SUCCESS.setBasalToCode()).isEqualTo(0) + assertThat(SetBasalProgramResult.INSULIN_DEFICIENCY.setBasalToCode()).isEqualTo(1) + assertThat(SetBasalProgramResult.EXPIRED.setBasalToCode()).isEqualTo(2) + assertThat(SetBasalProgramResult.LOW_VOLTAGE.setBasalToCode()).isEqualTo(3) + assertThat(SetBasalProgramResult.ABNORMAL_TEMP.setBasalToCode()).isEqualTo(4) + assertThat(SetBasalProgramResult.PUMP_ERROR.setBasalToCode()).isEqualTo(12) + assertThat(SetBasalProgramResult.ABNORMAL_PROGRAM.setBasalToCode()).isEqualTo(19) + assertThat(SetBasalProgramResult.EXCEED_LIMIT.setBasalToCode()).isEqualTo(20) + assertThat(SetBasalProgramResult.FAILED.setBasalToCode()).isEqualTo(-1) + } + + // endregion + + // region SetBolusProgramResult enum ------------------------------------------------------------ + + @Test fun `SetBolusProgramResult codeToSetBolusProgramCommand maps every known code`() { + assertThat(0.codeToSetBolusProgramCommand()).isEqualTo(SetBolusProgramResult.SUCCESS) + assertThat(1.codeToSetBolusProgramCommand()).isEqualTo(SetBolusProgramResult.INSULIN_DEFICIENCY) + assertThat(2.codeToSetBolusProgramCommand()).isEqualTo(SetBolusProgramResult.EXPIRED) + assertThat(3.codeToSetBolusProgramCommand()).isEqualTo(SetBolusProgramResult.LOW_VOLTAGE) + assertThat(4.codeToSetBolusProgramCommand()).isEqualTo(SetBolusProgramResult.ABNORMAL_TEMP) + assertThat(12.codeToSetBolusProgramCommand()).isEqualTo(SetBolusProgramResult.PUMP_ERROR) + assertThat(20.codeToSetBolusProgramCommand()).isEqualTo(SetBolusProgramResult.EXCEED_LIMIT) + } + + @Test fun `SetBolusProgramResult codeToSetBolusProgramCommand unknown is FAILED`() { + assertThat(7.codeToSetBolusProgramCommand()).isEqualTo(SetBolusProgramResult.FAILED) + } + + @Test fun `SetBolusProgramResult commandToCode encodes every member`() { + assertThat(SetBolusProgramResult.SUCCESS.setBolusToCode()).isEqualTo(0) + assertThat(SetBolusProgramResult.INSULIN_DEFICIENCY.setBolusToCode()).isEqualTo(1) + assertThat(SetBolusProgramResult.EXPIRED.setBolusToCode()).isEqualTo(2) + assertThat(SetBolusProgramResult.LOW_VOLTAGE.setBolusToCode()).isEqualTo(3) + assertThat(SetBolusProgramResult.ABNORMAL_TEMP.setBolusToCode()).isEqualTo(4) + assertThat(SetBolusProgramResult.PUMP_ERROR.setBolusToCode()).isEqualTo(12) + assertThat(SetBolusProgramResult.EXCEED_LIMIT.setBolusToCode()).isEqualTo(20) + assertThat(SetBolusProgramResult.FAILED.setBolusToCode()).isEqualTo(-1) + } + + // endregion + + // region StopPumpResult enum ------------------------------------------------------------------- + + @Test fun `StopPumpResult codeToStopPumpCommand maps every known code`() { + assertThat(0.codeToStopPumpCommand()).isEqualTo(StopPumpResult.BY_REQ) + assertThat(1.codeToStopPumpCommand()).isEqualTo(StopPumpResult.INSULIN_DEFICIENCY) + assertThat(2.codeToStopPumpCommand()).isEqualTo(StopPumpResult.ABNORMAL_PUMP) + assertThat(3.codeToStopPumpCommand()).isEqualTo(StopPumpResult.LOW_VOLTAGE) + assertThat(4.codeToStopPumpCommand()).isEqualTo(StopPumpResult.ABNORMAL_TEMP) + assertThat(5.codeToStopPumpCommand()).isEqualTo(StopPumpResult.NOT_USED) + assertThat(12.codeToStopPumpCommand()).isEqualTo(StopPumpResult.PUMP_ERROR) + assertThat(29.codeToStopPumpCommand()).isEqualTo(StopPumpResult.BY_LGS) + } + + @Test fun `StopPumpResult codeToStopPumpCommand unknown is ERROR`() { + assertThat(77.codeToStopPumpCommand()).isEqualTo(StopPumpResult.ERROR) + } + + @Test fun `StopPumpResult commandToCode encodes every member`() { + assertThat(StopPumpResult.BY_REQ.stopPumpToCode()).isEqualTo(0) + assertThat(StopPumpResult.INSULIN_DEFICIENCY.stopPumpToCode()).isEqualTo(1) + assertThat(StopPumpResult.ABNORMAL_PUMP.stopPumpToCode()).isEqualTo(2) + assertThat(StopPumpResult.LOW_VOLTAGE.stopPumpToCode()).isEqualTo(3) + assertThat(StopPumpResult.ABNORMAL_TEMP.stopPumpToCode()).isEqualTo(4) + assertThat(StopPumpResult.NOT_USED.stopPumpToCode()).isEqualTo(5) + assertThat(StopPumpResult.PUMP_ERROR.stopPumpToCode()).isEqualTo(12) + assertThat(StopPumpResult.BY_LGS.stopPumpToCode()).isEqualTo(29) + assertThat(StopPumpResult.ERROR.stopPumpToCode()).isEqualTo(-1) + } + + // endregion + + // region InfusionModeResult enum --------------------------------------------------------------- + + @Test fun `InfusionModeResult codeToInfusionModeCommand maps every known code`() { + assertThat(1.codeToInfusionModeCommand()).isEqualTo(InfusionModeResult.BASAL) + assertThat(2.codeToInfusionModeCommand()).isEqualTo(InfusionModeResult.TEMP_BASAL) + assertThat(3.codeToInfusionModeCommand()).isEqualTo(InfusionModeResult.IMME_BOLUS) + assertThat(4.codeToInfusionModeCommand()).isEqualTo(InfusionModeResult.EXTEND_IMME_BOLUS) + assertThat(5.codeToInfusionModeCommand()).isEqualTo(InfusionModeResult.EXTEND_BOLUS) + } + + @Test fun `InfusionModeResult codeToInfusionModeCommand unknown is ERROR`() { + assertThat(0.codeToInfusionModeCommand()).isEqualTo(InfusionModeResult.ERROR) + assertThat(6.codeToInfusionModeCommand()).isEqualTo(InfusionModeResult.ERROR) + } + + @Test fun `InfusionModeResult commandToCode encodes every member`() { + assertThat(InfusionModeResult.BASAL.infusionModeToCode()).isEqualTo(1) + assertThat(InfusionModeResult.TEMP_BASAL.infusionModeToCode()).isEqualTo(2) + assertThat(InfusionModeResult.IMME_BOLUS.infusionModeToCode()).isEqualTo(3) + assertThat(InfusionModeResult.EXTEND_IMME_BOLUS.infusionModeToCode()).isEqualTo(4) + assertThat(InfusionModeResult.EXTEND_BOLUS.infusionModeToCode()).isEqualTo(5) + assertThat(InfusionModeResult.ERROR.infusionModeToCode()).isEqualTo(-1) + } + + // endregion + + // region InfusionInfoResult enum --------------------------------------------------------------- + + @Test fun `InfusionInfoResult codeToInfusionInfoCommand maps every known code`() { + assertThat(0.codeToInfusionInfoCommand()).isEqualTo(InfusionInfoResult.BY_REQ) + assertThat(1.codeToInfusionInfoCommand()).isEqualTo(InfusionInfoResult.BY_REMAIN_REQ) + assertThat(2.codeToInfusionInfoCommand()).isEqualTo(InfusionInfoResult.BY_30MIN_RPT) + assertThat(3.codeToInfusionInfoCommand()).isEqualTo(InfusionInfoResult.BY_RECONNECT) + } + + @Test fun `InfusionInfoResult codeToInfusionInfoCommand unknown is ERROR`() { + assertThat(9.codeToInfusionInfoCommand()).isEqualTo(InfusionInfoResult.ERROR) + } + + @Test fun `InfusionInfoResult commandToCode encodes every member`() { + assertThat(InfusionInfoResult.BY_REQ.infusionInfoToCode()).isEqualTo(0) + assertThat(InfusionInfoResult.BY_REMAIN_REQ.infusionInfoToCode()).isEqualTo(1) + assertThat(InfusionInfoResult.BY_30MIN_RPT.infusionInfoToCode()).isEqualTo(2) + assertThat(InfusionInfoResult.BY_RECONNECT.infusionInfoToCode()).isEqualTo(3) + assertThat(InfusionInfoResult.ERROR.infusionInfoToCode()).isEqualTo(-1) + } + + // endregion + + // region PumpStateResult enum ------------------------------------------------------------------ + + @Test fun `PumpStateResult codeToPumpStateCommand maps every known code`() { + assertThat(0.codeToPumpStateCommand()).isEqualTo(PumpStateResult.READY) + assertThat(1.codeToPumpStateCommand()).isEqualTo(PumpStateResult.PRIMING) + assertThat(2.codeToPumpStateCommand()).isEqualTo(PumpStateResult.RUNNING) + } + + @Test fun `PumpStateResult codeToPumpStateCommand unknown or null is ERROR`() { + assertThat(3.codeToPumpStateCommand()).isEqualTo(PumpStateResult.ERROR) + val nullCode: Int? = null + assertThat(nullCode.codeToPumpStateCommand()).isEqualTo(PumpStateResult.ERROR) + } + + @Test fun `PumpStateResult commandToCode encodes every member`() { + assertThat(PumpStateResult.READY.pumpStateToCode()).isEqualTo(0) + assertThat(PumpStateResult.PRIMING.pumpStateToCode()).isEqualTo(1) + assertThat(PumpStateResult.RUNNING.pumpStateToCode()).isEqualTo(2) + assertThat(PumpStateResult.ERROR.pumpStateToCode()).isEqualTo(3) + } + + // endregion + + // region WarningMessageResult enum ------------------------------------------------------------- + + @Test fun `WarningMessageResult codeToWarningMessageCommand maps every known code`() { + assertThat(1.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.INSULIN_DEFICIENCY) + assertThat(2.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.EXPIRED) + assertThat(3.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.LOW_VOLTAGE) + assertThat(4.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.ABNORMAL_TEMP) + assertThat(5.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.NOT_USED) + assertThat(6.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.BLE_CONNECT) + assertThat(7.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.NOT_STARTED_BASAL) + assertThat(10.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.EXTENDED_EXPIRED) + assertThat(12.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.PUMP_ERROR) + assertThat(99.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.CANNULA_ERROR) + } + + @Test fun `WarningMessageResult codeToWarningMessageCommand unknown is ERROR`() { + assertThat(50.codeToWarningMessageCommand()).isEqualTo(WarningMessageResult.ERROR) + } + + @Test fun `WarningMessageResult commandToCode encodes every member`() { + assertThat(WarningMessageResult.INSULIN_DEFICIENCY.warningToCode()).isEqualTo(1) + assertThat(WarningMessageResult.EXPIRED.warningToCode()).isEqualTo(2) + assertThat(WarningMessageResult.LOW_VOLTAGE.warningToCode()).isEqualTo(3) + assertThat(WarningMessageResult.ABNORMAL_TEMP.warningToCode()).isEqualTo(4) + assertThat(WarningMessageResult.NOT_USED.warningToCode()).isEqualTo(5) + assertThat(WarningMessageResult.BLE_CONNECT.warningToCode()).isEqualTo(6) + assertThat(WarningMessageResult.NOT_STARTED_BASAL.warningToCode()).isEqualTo(7) + assertThat(WarningMessageResult.EXTENDED_EXPIRED.warningToCode()).isEqualTo(10) + assertThat(WarningMessageResult.PUMP_ERROR.warningToCode()).isEqualTo(12) + assertThat(WarningMessageResult.CANNULA_ERROR.warningToCode()).isEqualTo(99) + assertThat(WarningMessageResult.ERROR.warningToCode()).isEqualTo(-1) + } + + // endregion + + // region AlertMessageResult enum --------------------------------------------------------------- + + @Test fun `AlertMessageResult codeToAlertMessageCommand maps every known code`() { + assertThat(1.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.INSULIN_LOW) + assertThat(2.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.EXPIRED_ALERT) + assertThat(3.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.BATTERY_EXCEED) + assertThat(4.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.ABNORMAL_TEMP) + assertThat(5.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.NOT_USED) + assertThat(6.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.BLE_CONNECT) + assertThat(7.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.NOT_START_BASAL) + assertThat(8.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.PUMP_STOP_FINISH) + assertThat(10.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.EXTEND_EXPIRED) + } + + @Test fun `AlertMessageResult codeToAlertMessageCommand unknown is ERROR`() { + assertThat(42.codeToAlertMessageCommand()).isEqualTo(AlertMessageResult.ERROR) + } + + @Test fun `AlertMessageResult commandToCode encodes every member`() { + assertThat(AlertMessageResult.INSULIN_LOW.alertToCode()).isEqualTo(1) + assertThat(AlertMessageResult.EXPIRED_ALERT.alertToCode()).isEqualTo(2) + assertThat(AlertMessageResult.BATTERY_EXCEED.alertToCode()).isEqualTo(3) + assertThat(AlertMessageResult.ABNORMAL_TEMP.alertToCode()).isEqualTo(4) + assertThat(AlertMessageResult.NOT_USED.alertToCode()).isEqualTo(5) + assertThat(AlertMessageResult.BLE_CONNECT.alertToCode()).isEqualTo(6) + assertThat(AlertMessageResult.NOT_START_BASAL.alertToCode()).isEqualTo(7) + assertThat(AlertMessageResult.PUMP_STOP_FINISH.alertToCode()).isEqualTo(8) + assertThat(AlertMessageResult.EXTEND_EXPIRED.alertToCode()).isEqualTo(10) + assertThat(AlertMessageResult.ERROR.alertToCode()).isEqualTo(-1) + } + + // endregion + + // region NoticeMessageResult enum -------------------------------------------------------------- + + @Test fun `NoticeMessageResult codeToNoticeMessageCommand maps every known code`() { + assertThat(1.codeToNoticeMessageCommand()).isEqualTo(NoticeMessageResult.REMAIN_EXCEED) + assertThat(2.codeToNoticeMessageCommand()).isEqualTo(NoticeMessageResult.EXPIRED_NOTICE) + assertThat(3.codeToNoticeMessageCommand()).isEqualTo(NoticeMessageResult.INSPECTING) + assertThat(26.codeToNoticeMessageCommand()).isEqualTo(NoticeMessageResult.SYNC_TIME) + assertThat(27.codeToNoticeMessageCommand()).isEqualTo(NoticeMessageResult.GLUCOSE) + } + + @Test fun `NoticeMessageResult codeToNoticeMessageCommand unknown is ERROR`() { + assertThat(4.codeToNoticeMessageCommand()).isEqualTo(NoticeMessageResult.ERROR) + } + + @Test fun `NoticeMessageResult commandToCode encodes every member`() { + assertThat(NoticeMessageResult.REMAIN_EXCEED.noticeToCode()).isEqualTo(1) + assertThat(NoticeMessageResult.EXPIRED_NOTICE.noticeToCode()).isEqualTo(2) + assertThat(NoticeMessageResult.INSPECTING.noticeToCode()).isEqualTo(3) + assertThat(NoticeMessageResult.SYNC_TIME.noticeToCode()).isEqualTo(26) + assertThat(NoticeMessageResult.GLUCOSE.noticeToCode()).isEqualTo(27) + assertThat(NoticeMessageResult.ERROR.noticeToCode()).isEqualTo(-1) + } + + // endregion + + // region AlarmCause.fromTypeAndCode (used by the report result models) ------------------------- + + @Test fun `fromTypeAndCode resolves per-tier codes`() { + assertThat(AlarmCause.fromTypeAndCode(AlarmType.WARNING, 0x01)) + .isEqualTo(AlarmCause.ALARM_WARNING_LOW_INSULIN) + assertThat(AlarmCause.fromTypeAndCode(AlarmType.ALERT, 0x01)) + .isEqualTo(AlarmCause.ALARM_ALERT_OUT_OF_INSULIN) + assertThat(AlarmCause.fromTypeAndCode(AlarmType.NOTICE, 0x01)) + .isEqualTo(AlarmCause.ALARM_NOTICE_LOW_INSULIN) + } + + @Test fun `fromTypeAndCode distinguishes shared code by value`() { + assertThat(AlarmCause.fromTypeAndCode(AlarmType.NOTICE, 100, 1)) + .isEqualTo(AlarmCause.ALARM_NOTICE_LGS_FINISHED_DISCONNECTED_PATCH_OR_CGM) + assertThat(AlarmCause.fromTypeAndCode(AlarmType.NOTICE, 100, 5)) + .isEqualTo(AlarmCause.ALARM_NOTICE_LGS_FINISHED_HIGH_BG) + // No value -> the value-less LGS-finished catch-all entry. + assertThat(AlarmCause.fromTypeAndCode(AlarmType.NOTICE, 100)) + .isEqualTo(AlarmCause.ALARM_NOTICE_LGS_FINISHED_UNKNOWN) + } + + @Test fun `fromTypeAndCode returns ALARM_UNKNOWN for unmatched input`() { + assertThat(AlarmCause.fromTypeAndCode(AlarmType.WARNING, 0x7F)) + .isEqualTo(AlarmCause.ALARM_UNKNOWN) + assertThat(AlarmCause.fromTypeAndCode(AlarmType.UNKNOWN_TYPE, null)) + .isEqualTo(AlarmCause.ALARM_UNKNOWN) + } + + // endregion + + // region createPatchResultModel dispatch ------------------------------------------------------- + + @Test fun `patch dispatch - SetTime`() { + val m = createPatchResultModel(SetTimeResponse(TS, PATCH, result = 0)) + assertThat(m).isInstanceOf(SetTimeResultModel::class.java) + assertThat((m as SetTimeResultModel).result).isEqualTo(Result.SUCCESS) + } + + @Test fun `patch dispatch - PatchInformationInquiry`() { + val m = createPatchResultModel(PatchInformationInquiryResponse(TS, PATCH, result = 1, serialNum = "SN-9")) + assertThat(m).isInstanceOf(PatchInformationInquiryModel::class.java) + m as PatchInformationInquiryModel + assertThat(m.result).isEqualTo(Result.FAILED) + assertThat(m.serialNum).isEqualTo("SN-9") + } + + @Test fun `patch dispatch - PatchInformationInquiryDetail`() { + val m = createPatchResultModel( + PatchInformationInquiryDetailResponse(TS, PATCH, result = 0, firmVersion = "1.2.3", bootDateTime = "2026", modelName = "CL") + ) + assertThat(m).isInstanceOf(PatchInformationInquiryDetailModel::class.java) + m as PatchInformationInquiryDetailModel + assertThat(m.result).isEqualTo(Result.SUCCESS) + assertThat(m.firmwareVer).isEqualTo("1.2.3") + assertThat(m.bootDateTime).isEqualTo("2026") + assertThat(m.modelName).isEqualTo("CL") + } + + @Test fun `patch dispatch - SafetyCheck`() { + val m = createPatchResultModel(SafetyCheckResponse(TS, PATCH, result = 1, volume = 300, durationSeconds = 42)) + assertThat(m).isInstanceOf(SafetyCheckResultModel::class.java) + m as SafetyCheckResultModel + assertThat(m.result).isEqualTo(SafetyCheckResult.INSULIN_DEFICIENCY) + assertThat(m.volume).isEqualTo(300) + assertThat(m.durationSeconds).isEqualTo(42) + } + + @Test fun `patch dispatch - ThresholdSet`() { + val m = createPatchResultModel(ThresholdSetResponse(TS, PATCH, result = 0)) + assertThat(m).isInstanceOf(ThresholdSetResultModel::class.java) + assertThat((m as ThresholdSetResultModel).result).isEqualTo(Result.SUCCESS) + } + + @Test fun `patch dispatch - CannulaInsertion`() { + val m = createPatchResultModel(CannulaInsertionResponse(TS, PATCH, result = 0)) + assertThat(m).isInstanceOf(CannulaInsertionResultModel::class.java) + assertThat((m as CannulaInsertionResultModel).result).isEqualTo(Result.SUCCESS) + } + + @Test fun `patch dispatch - CannulaInsertionAck`() { + val m = createPatchResultModel(CannulaInsertionAckResponse(TS, PATCH, result = 1)) + assertThat(m).isInstanceOf(CannulaInsertionAckResultModel::class.java) + assertThat((m as CannulaInsertionAckResultModel).result).isEqualTo(Result.FAILED) + } + + @Test fun `patch dispatch - SetInfusionThreshold`() { + val m = createPatchResultModel(SetInfusionThresholdResponse(TS, PATCH, type = 7, result = 0)) + assertThat(m).isInstanceOf(SetInfusionThresholdResultModel::class.java) + m as SetInfusionThresholdResultModel + assertThat(m.result).isEqualTo(Result.SUCCESS) + assertThat(m.type).isEqualTo(7) + } + + @Test fun `patch dispatch - SetBuzzMode`() { + val m = createPatchResultModel(SetBuzzModeResponse(TS, PATCH, result = 0)) + assertThat(m).isInstanceOf(SetBuzzModeResultModel::class.java) + assertThat((m as SetBuzzModeResultModel).result).isEqualTo(Result.SUCCESS) + } + + @Test fun `patch dispatch - ClearReport to SetAlarmClear`() { + val m = createPatchResultModel(ClearReportResponse(TS, PATCH, result = 0, subId = 2, cause = 5)) + assertThat(m).isInstanceOf(SetAlarmClearResultModel::class.java) + m as SetAlarmClearResultModel + assertThat(m.result).isEqualTo(Result.SUCCESS) + assertThat(m.subId).isEqualTo(2) + assertThat(m.cause).isEqualTo(5) + } + + @Test fun `patch dispatch - SetExpiryExtend`() { + val m = createPatchResultModel(SetExpiryExtendResponse(TS, PATCH, result = 0)) + assertThat(m).isInstanceOf(ExtendPatchExpiryResultModel::class.java) + assertThat((m as ExtendPatchExpiryResultModel).result).isEqualTo(Result.SUCCESS) + } + + @Test fun `patch dispatch - StopPump`() { + val m = createPatchResultModel(StopPumpResponse(TS, PATCH, result = 0)) + assertThat(m).isInstanceOf(StopPumpResultModel::class.java) + assertThat((m as StopPumpResultModel).result).isEqualTo(Result.SUCCESS) + } + + @Test fun `patch dispatch - ResumePump maps stop-pump result and mode`() { + val m = createPatchResultModel(ResumePumpResponse(TS, PATCH, result = 29, mode = 1, causeId = 4)) + assertThat(m).isInstanceOf(ResumePumpResultModel::class.java) + m as ResumePumpResultModel + assertThat(m.result).isEqualTo(StopPumpResult.BY_LGS) + assertThat(m.mode).isEqualTo(InfusionModeResult.BASAL) + assertThat(m.subId).isEqualTo(4) + } + + @Test fun `patch dispatch - StopPumpReport maps renamed amount fields`() { + val m = createPatchResultModel( + StopPumpReportResponse( + TS, PATCH, result = 0, mode = 3, causeId = 12, + infusedBolusAmount = 1.5, unInfusedExtendBolusAmount = 0.4, temperature = 25 + ) + ) + assertThat(m).isInstanceOf(StopPumpReportResultModel::class.java) + m as StopPumpReportResultModel + assertThat(m.result).isEqualTo(StopPumpResult.BY_REQ) + assertThat(m.mode).isEqualTo(InfusionModeResult.IMME_BOLUS) + assertThat(m.subId).isEqualTo(12) + assertThat(m.infusedBolusInfusionAmount).isEqualTo(1.5) + assertThat(m.infusedBasalInfusionAmount).isEqualTo(0.4) + assertThat(m.temperature).isEqualTo(25) + } + + @Test fun `patch dispatch - RetrieveInfusionStatus maps enums and passthroughs`() { + val m = createPatchResultModel( + RetrieveInfusionStatusResponse( + TS, PATCH, subId = 2, runningMinutes = 60, remains = 120.0, + infusedTotalBasalAmount = 10.0, infusedTotalBolusAmount = 5.0, + pumpState = 2, mode = 5, infusedSetMinutes = 30, + currentInfusedProgramVolume = 2.5, realInfusedTime = 15 + ) + ) + assertThat(m).isInstanceOf(InfusionInfoReportResultModel::class.java) + m as InfusionInfoReportResultModel + assertThat(m.subId).isEqualTo(InfusionInfoResult.BY_30MIN_RPT) + assertThat(m.pumpState).isEqualTo(PumpStateResult.RUNNING) + assertThat(m.mode).isEqualTo(InfusionModeResult.EXTEND_BOLUS) + assertThat(m.runningMinutes).isEqualTo(60) + assertThat(m.remains).isEqualTo(120.0) + assertThat(m.infusedTotalBasalAmount).isEqualTo(10.0) + assertThat(m.infusedTotalBolusAmount).isEqualTo(5.0) + assertThat(m.infuseSetMinutes).isEqualTo(30) + assertThat(m.currentInfusedProgramVolume).isEqualTo(2.5) + assertThat(m.realInfusedTime).isEqualTo(15) + } + + @Test fun `patch dispatch - SetApplicationStatus`() { + val m = createPatchResultModel(SetApplicationStatusResponse(TS, PATCH, status = 3)) + assertThat(m).isInstanceOf(SetApplicationStatusResultModel::class.java) + assertThat((m as SetApplicationStatusResultModel).status).isEqualTo(3) + } + + @Test fun `patch dispatch - RetrieveAddress`() { + val m = createPatchResultModel(RetrieveAddressResponse(TS, PATCH, address = "AA:BB", checkSum = "CS")) + assertThat(m).isInstanceOf(RetrieveAddressResultModel::class.java) + m as RetrieveAddressResultModel + assertThat(m.address).isEqualTo("AA:BB") + assertThat(m.checkSum).isEqualTo("CS") + } + + @Test fun `patch dispatch - SetDiscard`() { + val m = createPatchResultModel(SetDiscardResponse(TS, PATCH, result = 0)) + assertThat(m).isInstanceOf(DiscardPatchResultModel::class.java) + assertThat((m as DiscardPatchResultModel).result).isEqualTo(Result.SUCCESS) + } + + @Test fun `patch dispatch - RecoveryPatch`() { + val m = createPatchResultModel(RecoveryPatchResponse(TS, PATCH)) + assertThat(m).isInstanceOf(RecoveryPatchReportResultModel::class.java) + } + + @Test fun `patch dispatch - WarningReport maps cause via AlarmCause`() { + val m = createPatchResultModel(WarningReportResponse(TS, PATCH, cause = 0x01, value = 50)) + assertThat(m).isInstanceOf(WarningReportResultModel::class.java) + m as WarningReportResultModel + assertThat(m.cause).isEqualTo(AlarmCause.ALARM_WARNING_LOW_INSULIN) + assertThat(m.value).isEqualTo(50) + } + + @Test fun `patch dispatch - AlertReport maps cause via AlarmCause`() { + val m = createPatchResultModel(AlertReportResponse(TS, PATCH, cause = 0x02, value = 7)) + assertThat(m).isInstanceOf(AlertReportResultModel::class.java) + m as AlertReportResultModel + assertThat(m.cause).isEqualTo(AlarmCause.ALARM_ALERT_PATCH_EXPIRED_PHASE_2) + assertThat(m.value).isEqualTo(7) + } + + @Test fun `patch dispatch - NoticeReport maps cause via AlarmCause`() { + val m = createPatchResultModel(NoticeReportResponse(TS, PATCH, cause = 0x02, value = 3)) + assertThat(m).isInstanceOf(NoticeReportResultModel::class.java) + m as NoticeReportResultModel + assertThat(m.cause).isEqualTo(AlarmCause.ALARM_NOTICE_PATCH_EXPIRED) + assertThat(m.value).isEqualTo(3) + } + + @Test fun `patch dispatch - AppAuthRpt`() { + val m = createPatchResultModel(AppAuthRptResponse(TS, PATCH, value = 88)) + assertThat(m).isInstanceOf(AppAuthAckReportResultModel::class.java) + assertThat((m as AppAuthAckReportResultModel).value).isEqualTo(88) + } + + @Test fun `patch dispatch - AdditionalPriming`() { + val m = createPatchResultModel(AdditionalPrimingResponse(TS, PATCH, result = 1)) + assertThat(m).isInstanceOf(AdditionalPrimingResultModel::class.java) + assertThat((m as AdditionalPrimingResultModel).result).isEqualTo(Result.FAILED) + } + + @Test fun `patch dispatch - SetThresholdNotice`() { + val m = createPatchResultModel(SetThresholdNoticeResponse(TS, PATCH, result = 0, type = 4)) + assertThat(m).isInstanceOf(SetThresholdNoticeResultModel::class.java) + m as SetThresholdNoticeResultModel + assertThat(m.type).isEqualTo(4) + assertThat(m.result).isEqualTo(Result.SUCCESS) + } + + @Test fun `patch dispatch - SetAlertAlarmModel`() { + val m = createPatchResultModel(SetAlertAlarmModelResponse(TS, PATCH, result = 0)) + assertThat(m).isInstanceOf(AlertAlarmSetResultModel::class.java) + assertThat((m as AlertAlarmSetResultModel).result).isEqualTo(Result.SUCCESS) + } + + @Test fun `patch dispatch - AppAuthAckRpt`() { + val m = createPatchResultModel(AppAuthAckRptResponse(TS, PATCH, result = 0)) + assertThat(m).isInstanceOf(AppAuthAckResultModel::class.java) + assertThat((m as AppAuthAckResultModel).result).isEqualTo(Result.SUCCESS) + } + + @Test fun `patch dispatch - AppAlarmOff`() { + val m = createPatchResultModel(AppAlarmOffResponse(TS, PATCH, result = 1)) + assertThat(m).isInstanceOf(AppAlarmClearResultModel::class.java) + assertThat((m as AppAlarmClearResultModel).result).isEqualTo(Result.FAILED) + } + + @Test fun `patch dispatch - RetrieveOperationInfo passthrough`() { + val m = createPatchResultModel( + RetrieveOperationInfoResponse(TS, PATCH, mode = 2, pulseCnt = 10, totalNo = 100, count = 5, useMinutes = 33, remains = 77.5) + ) + assertThat(m).isInstanceOf(RetrieveOperationInfoResultModel::class.java) + m as RetrieveOperationInfoResultModel + assertThat(m.mode).isEqualTo(2) + assertThat(m.pulseCnt).isEqualTo(10) + assertThat(m.totalNo).isEqualTo(100) + assertThat(m.count).isEqualTo(5) + assertThat(m.useMinutes).isEqualTo(33) + assertThat(m.remains).isEqualTo(77.5) + } + + @Test fun `patch dispatch - CheckBuzz to AppBuzz`() { + val m = createPatchResultModel(CheckBuzzResponse(TS, PATCH, result = 0)) + assertThat(m).isInstanceOf(AppBuzzResultModel::class.java) + assertThat((m as AppBuzzResultModel).result).isEqualTo(Result.SUCCESS) + } + + @Test fun `patch dispatch returns null when command is not a patch protocol`() { + assertThat(createPatchResultModel(SetTimeResponse(TS, BASAL, result = 0))).isNull() + } + + @Test fun `patch dispatch returns null for an unhandled response type`() { + // A bolus response carried under a patch opcode matches no patch branch -> null. + assertThat(createPatchResultModel(StartExtendBolusResponse(TS, PATCH, result = 0, expectedTime = 5))).isNull() + } + + // endregion + + // region createBasalResultModel dispatch ------------------------------------------------------- + + @Test fun `basal dispatch - SetBasalProgram`() { + val m = createBasalResultModel(SetBasalProgramResponse(TS, BASAL, result = 20)) + assertThat(m).isInstanceOf(SetBasalProgramResultModel::class.java) + assertThat((m as SetBasalProgramResultModel).result).isEqualTo(SetBasalProgramResult.EXCEED_LIMIT) + } + + @Test fun `basal dispatch - SetBasalProgramAdditional`() { + val m = createBasalResultModel(SetBasalProgramAdditionalResponse(TS, BASAL, result = 0)) + assertThat(m).isInstanceOf(SetBasalProgramAdditionalResultModel::class.java) + assertThat((m as SetBasalProgramAdditionalResultModel).result).isEqualTo(SetBasalProgramResult.SUCCESS) + } + + @Test fun `basal dispatch - UpdateBasalProgram`() { + val m = createBasalResultModel(UpdateBasalProgramResponse(TS, BASAL, result = 1)) + assertThat(m).isInstanceOf(UpdateBasalProgramResultModel::class.java) + assertThat((m as UpdateBasalProgramResultModel).result).isEqualTo(SetBasalProgramResult.INSULIN_DEFICIENCY) + } + + @Test fun `basal dispatch - UpdateBasalProgramAdditional`() { + val m = createBasalResultModel(UpdateBasalProgramAdditionalResponse(TS, BASAL, result = 12)) + assertThat(m).isInstanceOf(UpdateBasalProgramAdditionalResultModel::class.java) + assertThat((m as UpdateBasalProgramAdditionalResultModel).result).isEqualTo(SetBasalProgramResult.PUMP_ERROR) + } + + @Test fun `basal dispatch - StartTempBasalProgram`() { + val m = createBasalResultModel(StartTempBasalProgramResponse(TS, BASAL, result = 19)) + assertThat(m).isInstanceOf(StartTempBasalProgramResultModel::class.java) + assertThat((m as StartTempBasalProgramResultModel).result).isEqualTo(SetBasalProgramResult.ABNORMAL_PROGRAM) + } + + @Test fun `basal dispatch - CancelTempBasalProgram uses plain Result`() { + val m = createBasalResultModel(CancelTempBasalProgramResponse(TS, BASAL, result = 0)) + assertThat(m).isInstanceOf(CancelTempBasalProgramResultModel::class.java) + assertThat((m as CancelTempBasalProgramResultModel).result).isEqualTo(Result.SUCCESS) + } + + @Test fun `basal dispatch - StartBasalProgram`() { + val m = createBasalResultModel(StartBasalProgramResponse(TS, BASAL)) + assertThat(m).isInstanceOf(StartBasalProgramResultModel::class.java) + } + + @Test fun `basal dispatch - ResumeBasalProgram passthrough`() { + val m = createBasalResultModel( + ResumeBasalProgramResponse(TS, BASAL, segmentNo = 3, infusionSpeed = 1.25, infusionPeriod = 30, insulinRemains = 88.0) + ) + assertThat(m).isInstanceOf(BasalInfusionResumeResultModel::class.java) + m as BasalInfusionResumeResultModel + assertThat(m.segmentNo).isEqualTo(3) + assertThat(m.infusionSpeed).isEqualTo(1.25) + assertThat(m.infusionPeriod).isEqualTo(30) + assertThat(m.insulinRemains).isEqualTo(88.0) + } + + @Test fun `basal dispatch returns null when command is not a basal protocol`() { + assertThat(createBasalResultModel(SetBasalProgramResponse(TS, PATCH, result = 0))).isNull() + } + + // endregion + + // region createBolusResultModel dispatch ------------------------------------------------------- + + @Test fun `bolus dispatch - StartImmeBolus`() { + val m = createBolusResultModel(StartImmeBolusResponse(TS, BOLUS, result = 0, actionId = 9, expectedTime = 120, remain = 44.0)) + assertThat(m).isInstanceOf(StartImmeBolusResultModel::class.java) + m as StartImmeBolusResultModel + assertThat(m.result).isEqualTo(SetBolusProgramResult.SUCCESS) + assertThat(m.actionId).isEqualTo(9) + assertThat(m.expectedTime).isEqualTo(120) + assertThat(m.remains).isEqualTo(44.0) + } + + @Test fun `bolus dispatch - CancelImmeBolus uses plain Result`() { + val m = createBolusResultModel(CancelImmeBolusResponse(TS, BOLUS, result = 1, remains = 10.0, infusedAmount = 2.0)) + assertThat(m).isInstanceOf(CancelImmeBolusResultModel::class.java) + m as CancelImmeBolusResultModel + assertThat(m.result).isEqualTo(Result.FAILED) + assertThat(m.remains).isEqualTo(10.0) + assertThat(m.infusedAmount).isEqualTo(2.0) + } + + @Test fun `bolus dispatch - StartExtendBolus`() { + val m = createBolusResultModel(StartExtendBolusResponse(TS, BOLUS, result = 20, expectedTime = 300)) + assertThat(m).isInstanceOf(StartExtendBolusResultModel::class.java) + m as StartExtendBolusResultModel + assertThat(m.result).isEqualTo(SetBolusProgramResult.EXCEED_LIMIT) + assertThat(m.expectedTime).isEqualTo(300) + } + + @Test fun `bolus dispatch - CancelExtendBolus uses plain Result`() { + val m = createBolusResultModel(CancelExtendBolusResponse(TS, BOLUS, result = 0, infusedAmount = 3.5)) + assertThat(m).isInstanceOf(CancelExtendBolusResultModel::class.java) + m as CancelExtendBolusResultModel + assertThat(m.result).isEqualTo(Result.SUCCESS) + assertThat(m.infusedAmount).isEqualTo(3.5) + } + + @Test fun `bolus dispatch - DelayExtendBolus passthrough`() { + val m = createBolusResultModel(DelayExtendBolusResponse(TS, BOLUS, delayedAmount = 1.75, expectedTime = 90)) + assertThat(m).isInstanceOf(DelayExtendBolusReportResultModel::class.java) + m as DelayExtendBolusReportResultModel + assertThat(m.delayedAmount).isEqualTo(1.75) + assertThat(m.expectedTime).isEqualTo(90) + } + + @Test fun `bolus dispatch returns null when command is not a bolus protocol`() { + assertThat(createBolusResultModel(StartImmeBolusResponse(TS, PATCH, result = 0, actionId = 0, expectedTime = 0, remain = 0.0))).isNull() + } + + // endregion + + // region protocol classifiers ------------------------------------------------------------------ + + @Test fun `isPatchProtocol true for patch opcodes`() { + listOf( + 0x11, 0x71, 0x12, 0x72, 0x15, 0x75, 0x16, 0x76, 0x17, 0x77, 0x18, 0x78, 0x19, 0x79, + 0x1A, 0x7A, 0x1B, 0x7B, 0x1C, 0x7C, 0x26, 0x86, 0x27, 0x87, 0x2A, 0x31, 0x91, 0x33, + 0x93, 0x94, 0x35, 0x95, 0x36, 0x96, 0x37, 0x97, 0x38, 0x98, 0x39, 0x99, 0x3A, 0x9A, + 0x3D, 0x9D, 0x9E, 0x3B, 0x9B, 0x3F, 0x9F, 0x4D, 0xA1, 0xA2, 0xA3, 0x47, 0xA7, 0x4A, + 0xBA, 0x4B, 0x1D, 0x7D, 0x48, 0xA8, 0xBB + ).forEach { assertThat(isPatchProtocol(it)).isTrue() } + } + + @Test fun `isPatchProtocol false for non-patch opcodes and unknown`() { + listOf( + 0x13, 0x73, 0x14, 0x74, 0x21, 0x81, 0x22, 0x82, 0x23, 0x83, 0x24, 0x84, 0x25, 0x85, + 0x88, 0x29, 0x89, 0x2B, 0x8B, 0x2C, 0x8C, 0x2D, 0x8D, 0x9C, 0xFF + ).forEach { assertThat(isPatchProtocol(it)).isFalse() } + } + + @Test fun `isBasalProtocol true for basal opcodes`() { + listOf(0x13, 0x73, 0x14, 0x74, 0x21, 0x81, 0x22, 0x82, 0x23, 0x83, 0x88, 0x2B, 0x8B, 0x2D, 0x8D) + .forEach { assertThat(isBasalProtocol(it)).isTrue() } + } + + @Test fun `isBasalProtocol false for non-basal opcodes and unknown`() { + listOf(0x11, 0x12, 0x24, 0x84, 0x25, 0x29, 0x2C, 0xFF) + .forEach { assertThat(isBasalProtocol(it)).isFalse() } + } + + @Test fun `isBolusProtocol true for bolus opcodes`() { + listOf(0x24, 0x84, 0x25, 0x85, 0x29, 0x89, 0x2C, 0x8C) + .forEach { assertThat(isBolusProtocol(it)).isTrue() } + } + + @Test fun `isBolusProtocol false for non-bolus opcodes and unknown`() { + listOf(0x11, 0x13, 0x21, 0x22, 0x88, 0x2B, 0x2D, 0xFF) + .forEach { assertThat(isBolusProtocol(it)).isFalse() } + } + + @Test fun `protocol families are mutually exclusive for the representative opcodes`() { + // Patch opcode -> only patch. + assertThat(isPatchProtocol(PATCH)).isTrue() + assertThat(isBasalProtocol(PATCH)).isFalse() + assertThat(isBolusProtocol(PATCH)).isFalse() + // Basal opcode -> only basal. + assertThat(isPatchProtocol(BASAL)).isFalse() + assertThat(isBasalProtocol(BASAL)).isTrue() + assertThat(isBolusProtocol(BASAL)).isFalse() + // Bolus opcode -> only bolus. + assertThat(isPatchProtocol(BOLUS)).isFalse() + assertThat(isBasalProtocol(BOLUS)).isFalse() + assertThat(isBolusProtocol(BOLUS)).isTrue() + } + + // endregion + + // region response data-class basics ------------------------------------------------------------ + + @Test fun `response data classes expose interface fields and copy semantics`() { + val r = StopPumpReportResponse( + timestamp = TS, command = PATCH, result = 0, mode = 1, causeId = 2, + infusedBolusAmount = 1.0, unInfusedExtendBolusAmount = 0.5, temperature = 20 + ) + assertThat(r.timestamp).isEqualTo(TS) + assertThat(r.command).isEqualTo(PATCH) + assertThat(r.copy(temperature = 30).temperature).isEqualTo(30) + assertThat(r.copy(temperature = 30)).isNotEqualTo(r) + assertThat(r.copy()).isEqualTo(r) + } + + // endregion +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoProtocolCheckerTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoProtocolCheckerTest.kt new file mode 100644 index 000000000000..eedc5f7020be --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/model/bt/CarelevoProtocolCheckerTest.kt @@ -0,0 +1,223 @@ +package app.aaps.pump.carelevo.domain.model.bt + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * Exhaustive coverage of the three opcode classifiers in CarelevoProtocolChecker.kt. + * + * Each classifier is a `when` over a fixed set of command opcodes. The tests below hit every + * labeled branch (both the `true` and the `false` arms) plus the `else` fallback, and assert the + * three classifiers are mutually exclusive. + */ +internal class CarelevoProtocolCheckerTest { + + // ---- Expected TRUE opcode sets, transcribed from the source `when` bodies ------------------- + + private val patchTrue = setOf( + 0x11, 0x71, 0x12, 0x72, + 0x15, 0x75, 0x16, 0x76, 0x17, 0x77, 0x18, 0x78, 0x19, 0x79, 0x1A, 0x7A, 0x1B, 0x7B, 0x1C, 0x7C, + 0x26, 0x86, 0x27, 0x87, + 0x2A, + 0x31, 0x91, 0x33, 0x93, 0x94, 0x35, 0x95, 0x36, 0x96, 0x37, 0x97, 0x38, 0x98, 0x39, 0x99, 0x3A, 0x9A, + 0x3D, 0x9D, 0x9E, 0x3B, 0x9B, 0x3F, 0x9F, + 0x4D, 0xA1, 0xA2, 0xA3, 0x47, 0xA7, + 0x4A, 0xBA, 0x4B, + 0x1D, 0x7D, + 0x48, 0xA8, + 0xBB + ) + + private val basalTrue = setOf( + 0x13, 0x73, 0x14, 0x74, + 0x21, 0x81, 0x22, 0x82, 0x23, 0x83, + 0x88, + 0x2B, 0x8B, + 0x2D, 0x8D + ) + + private val bolusTrue = setOf( + 0x24, 0x84, 0x25, 0x85, + 0x29, 0x89, + 0x2C, 0x8C + ) + + /** + * Every opcode that appears as a `when` label in any of the three classifiers. 0x9C is the only + * labeled opcode that is `false` in all three, so it is added on top of the three TRUE unions. + */ + private val universe: Set = patchTrue + basalTrue + bolusTrue + setOf(0x9C) + + /** Opcodes that are not labeled anywhere → must fall through to the `else -> false` arm. */ + private val unknownOpcodes = listOf( + 0x00, 0x01, 0x10, 0x1E, 0x1F, 0x20, 0x28, 0x2E, 0x2F, 0x30, 0x32, 0x34, 0x3C, 0x3E, + 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x49, 0x4C, 0x4E, 0x4F, + 0x70, 0x80, 0x90, 0xA0, 0xB0, 0xBC, 0xC0, 0xFF, 0x100, 0x1000, -1, -0x11 + ) + + // ---- Full-universe set checks (covers every labeled branch in every classifier) ------------- + + @Test + fun `isPatchProtocol returns true for exactly the patch opcode set`() { + val actualTrue = universe.filter { isPatchProtocol(it) }.toSet() + assertThat(actualTrue).isEqualTo(patchTrue) + } + + @Test + fun `isBasalProtocol returns true for exactly the basal opcode set`() { + val actualTrue = universe.filter { isBasalProtocol(it) }.toSet() + assertThat(actualTrue).isEqualTo(basalTrue) + } + + @Test + fun `isBolusProtocol returns true for exactly the bolus opcode set`() { + val actualTrue = universe.filter { isBolusProtocol(it) }.toSet() + assertThat(actualTrue).isEqualTo(bolusTrue) + } + + @Test + fun `every labeled opcode is classified false by the two classifiers that do not own it`() { + universe.forEach { op -> + val hits = listOf(isPatchProtocol(op), isBasalProtocol(op), isBolusProtocol(op)).count { it } + // 0x9C is false in all three; every other labeled opcode is true in exactly one. + val expectedHits = if (op == 0x9C) 0 else 1 + assertThat(hits).isEqualTo(expectedHits) + } + } + + @Test + fun `the three true sets are mutually exclusive`() { + assertThat(patchTrue.intersect(basalTrue)).isEmpty() + assertThat(patchTrue.intersect(bolusTrue)).isEmpty() + assertThat(basalTrue.intersect(bolusTrue)).isEmpty() + } + + // ---- else-branch / unknown-opcode coverage -------------------------------------------------- + + @Test + fun `unknown opcodes fall through to false in all three classifiers`() { + unknownOpcodes.forEach { op -> + assertThat(isPatchProtocol(op)).isFalse() + assertThat(isBasalProtocol(op)).isFalse() + assertThat(isBolusProtocol(op)).isFalse() + } + } + + @Test + fun `negative and out-of-byte-range opcodes are not patch, basal or bolus`() { + listOf(-1, -0x100, 0x100, 0x1FF, Int.MAX_VALUE, Int.MIN_VALUE).forEach { op -> + assertThat(isPatchProtocol(op)).isFalse() + assertThat(isBasalProtocol(op)).isFalse() + assertThat(isBolusProtocol(op)).isFalse() + } + } + + // ---- Expected-count guards (catch accidental additions / deletions of arms) ------------------ + + @Test + fun `patch true set has the expected cardinality`() { + assertThat(patchTrue).hasSize(63) + } + + @Test + fun `basal true set has the expected cardinality`() { + assertThat(basalTrue).hasSize(15) + } + + @Test + fun `bolus true set has the expected cardinality`() { + assertThat(bolusTrue).hasSize(8) + } + + // ---- Representative single-opcode checks (documented / notable branches) -------------------- + + @Test + fun `patch write opcodes 0x11 and 0x71 are patch protocol`() { + assertThat(isPatchProtocol(0x11)).isTrue() + assertThat(isPatchProtocol(0x71)).isTrue() + assertThat(isBasalProtocol(0x11)).isFalse() + assertThat(isBolusProtocol(0x11)).isFalse() + } + + @Test + fun `0x2A is patch only`() { + assertThat(isPatchProtocol(0x2A)).isTrue() + assertThat(isBasalProtocol(0x2A)).isFalse() + assertThat(isBolusProtocol(0x2A)).isFalse() + } + + @Test + fun `0xBB is a patch-only opcode present in no other classifier`() { + assertThat(isPatchProtocol(0xBB)).isTrue() + assertThat(isBasalProtocol(0xBB)).isFalse() + assertThat(isBolusProtocol(0xBB)).isFalse() + } + + @Test + fun `0x9C is explicitly false in all three classifiers`() { + assertThat(isPatchProtocol(0x9C)).isFalse() + assertThat(isBasalProtocol(0x9C)).isFalse() + assertThat(isBolusProtocol(0x9C)).isFalse() + } + + @Test + fun `0x4D is patch protocol despite being a false-duplicate label in basal and bolus`() { + assertThat(isPatchProtocol(0x4D)).isTrue() + assertThat(isBasalProtocol(0x4D)).isFalse() + assertThat(isBolusProtocol(0x4D)).isFalse() + } + + @Test + fun `basal-rate opcodes 0x13 0x14 and 0x21 are basal protocol`() { + assertThat(isBasalProtocol(0x13)).isTrue() + assertThat(isBasalProtocol(0x14)).isTrue() + assertThat(isBasalProtocol(0x21)).isTrue() + assertThat(isPatchProtocol(0x13)).isFalse() + assertThat(isBolusProtocol(0x13)).isFalse() + } + + @Test + fun `0x88 is basal only`() { + assertThat(isBasalProtocol(0x88)).isTrue() + assertThat(isPatchProtocol(0x88)).isFalse() + assertThat(isBolusProtocol(0x88)).isFalse() + } + + @Test + fun `bolus opcodes 0x24 0x25 and 0x29 are bolus protocol`() { + assertThat(isBolusProtocol(0x24)).isTrue() + assertThat(isBolusProtocol(0x25)).isTrue() + assertThat(isBolusProtocol(0x29)).isTrue() + assertThat(isPatchProtocol(0x24)).isFalse() + assertThat(isBasalProtocol(0x24)).isFalse() + } + + @Test + fun `bolus opcodes 0x2C and 0x8C are bolus protocol`() { + assertThat(isBolusProtocol(0x2C)).isTrue() + assertThat(isBolusProtocol(0x8C)).isTrue() + assertThat(isPatchProtocol(0x2C)).isFalse() + assertThat(isBasalProtocol(0x2C)).isFalse() + } + + @Test + fun `0x28 is a gap between labeled opcodes and matches nothing`() { + assertThat(isPatchProtocol(0x28)).isFalse() + assertThat(isBasalProtocol(0x28)).isFalse() + assertThat(isBolusProtocol(0x28)).isFalse() + } + + @Test + fun `zero opcode matches nothing`() { + assertThat(isPatchProtocol(0x00)).isFalse() + assertThat(isBasalProtocol(0x00)).isFalse() + assertThat(isBolusProtocol(0x00)).isFalse() + } + + @Test + fun `high-nibble mirror opcodes 0x94 0x9D and 0x9E are patch protocol`() { + assertThat(isPatchProtocol(0x94)).isTrue() + assertThat(isPatchProtocol(0x9D)).isTrue() + assertThat(isPatchProtocol(0x9E)).isTrue() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/AlarmClearPatchDiscardUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/AlarmClearPatchDiscardUseCaseTest.kt new file mode 100644 index 000000000000..8c6a131b4879 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/AlarmClearPatchDiscardUseCaseTest.kt @@ -0,0 +1,218 @@ +package app.aaps.pump.carelevo.domain.usecase.alarm + +import app.aaps.pump.carelevo.domain.model.userSetting.CarelevoUserSettingInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoAlarmInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoUserSettingInfoRepository +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Completable +import org.joda.time.DateTime +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +/** + * Covers [AlarmClearPatchDiscardUseCase.persistAlarmDiscarded]: the happy path plus every + * failure point that must abort the discard bookkeeping and report false. + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class AlarmClearPatchDiscardUseCaseTest { + + @Mock lateinit var alarmRepository: CarelevoAlarmInfoRepository + @Mock lateinit var patchInfoRepository: CarelevoPatchInfoRepository + @Mock lateinit var userSettingInfoRepository: CarelevoUserSettingInfoRepository + @Mock lateinit var infusionInfoRepository: CarelevoInfusionInfoRepository + + private lateinit var sut: AlarmClearPatchDiscardUseCase + + private val storedUserSetting = CarelevoUserSettingInfoDomainModel( + createdAt = DateTime.parse("2026-03-01T08:00:00Z"), + updatedAt = DateTime.parse("2026-03-02T08:00:00Z"), + lowInsulinNoticeAmount = 20, + maxBasalSpeed = 2.5, + maxBolusDose = 10.0, + needLowInsulinNoticeAmountSyncPatch = true, + needMaxBasalSpeedSyncPatch = true, + needMaxBolusDoseSyncPatch = true + ) + + @BeforeEach + fun setUp() { + sut = AlarmClearPatchDiscardUseCase( + alarmRepository = alarmRepository, + patchInfoRepository = patchInfoRepository, + userSettingInfoRepository = userSettingInfoRepository, + infusionInfoRepository = infusionInfoRepository + ) + givenAllWritesSucceed() + } + + private fun givenAllWritesSucceed() { + whenever(alarmRepository.removeAlarm(any())).thenReturn(Completable.complete()) + whenever(userSettingInfoRepository.getUserSettingInfoBySync()).thenReturn(storedUserSetting) + whenever(userSettingInfoRepository.updateUserSettingInfo(any())).thenReturn(true) + whenever(infusionInfoRepository.deleteInfusionInfo()).thenReturn(true) + whenever(patchInfoRepository.deletePatchInfo()).thenReturn(true) + } + + // ---------- happy path ---------- + + @Test + fun persistAlarmDiscarded_returns_true_when_all_writes_succeed() { + assertThat(sut.persistAlarmDiscarded("alarm-1")).isTrue() + } + + @Test + fun persistAlarmDiscarded_acks_alarm_then_clears_settings_then_deletes_records_in_order() { + sut.persistAlarmDiscarded("alarm-1") + + inOrder(alarmRepository, userSettingInfoRepository, infusionInfoRepository, patchInfoRepository) { + verify(alarmRepository).removeAlarm(eq("alarm-1")) + verify(userSettingInfoRepository).updateUserSettingInfo(any()) + verify(infusionInfoRepository).deleteInfusionInfo() + verify(patchInfoRepository).deletePatchInfo() + } + } + + @Test + fun persistAlarmDiscarded_clears_all_three_patch_sync_flags() { + sut.persistAlarmDiscarded("alarm-1") + + val captor = argumentCaptor() + verify(userSettingInfoRepository).updateUserSettingInfo(captor.capture()) + + with(captor.firstValue) { + assertThat(needMaxBolusDoseSyncPatch).isFalse() + assertThat(needMaxBasalSpeedSyncPatch).isFalse() + assertThat(needLowInsulinNoticeAmountSyncPatch).isFalse() + } + } + + @Test + fun persistAlarmDiscarded_preserves_user_setting_values_and_bumps_updatedAt() { + val before = DateTime.now() + + sut.persistAlarmDiscarded("alarm-1") + + val captor = argumentCaptor() + verify(userSettingInfoRepository).updateUserSettingInfo(captor.capture()) + + with(captor.firstValue) { + // The user's configured limits survive a discard; only the sync flags reset. + assertThat(lowInsulinNoticeAmount).isEqualTo(20) + assertThat(maxBasalSpeed).isEqualTo(2.5) + assertThat(maxBolusDose).isEqualTo(10.0) + assertThat(createdAt).isEqualTo(storedUserSetting.createdAt) + assertThat(updatedAt.isBefore(before)).isFalse() + } + } + + // ---------- failure paths ---------- + + @Test + fun persistAlarmDiscarded_returns_false_when_alarm_removal_errors() { + whenever(alarmRepository.removeAlarm(any())).thenReturn(Completable.error(RuntimeException("db down"))) + + assertThat(sut.persistAlarmDiscarded("alarm-1")).isFalse() + } + + @Test + fun persistAlarmDiscarded_skips_all_bookkeeping_when_alarm_removal_errors() { + whenever(alarmRepository.removeAlarm(any())).thenReturn(Completable.error(RuntimeException("db down"))) + + sut.persistAlarmDiscarded("alarm-1") + + verify(userSettingInfoRepository, never()).updateUserSettingInfo(any()) + verify(infusionInfoRepository, never()).deleteInfusionInfo() + verify(patchInfoRepository, never()).deletePatchInfo() + } + + @Test + fun persistAlarmDiscarded_returns_false_when_no_user_setting_record_exists() { + whenever(userSettingInfoRepository.getUserSettingInfoBySync()).thenReturn(null) + + assertThat(sut.persistAlarmDiscarded("alarm-1")).isFalse() + } + + @Test + fun persistAlarmDiscarded_does_not_delete_records_when_no_user_setting_record_exists() { + whenever(userSettingInfoRepository.getUserSettingInfoBySync()).thenReturn(null) + + sut.persistAlarmDiscarded("alarm-1") + + verify(userSettingInfoRepository, never()).updateUserSettingInfo(any()) + verify(infusionInfoRepository, never()).deleteInfusionInfo() + verify(patchInfoRepository, never()).deletePatchInfo() + } + + @Test + fun persistAlarmDiscarded_returns_false_when_user_setting_update_fails() { + whenever(userSettingInfoRepository.updateUserSettingInfo(any())).thenReturn(false) + + assertThat(sut.persistAlarmDiscarded("alarm-1")).isFalse() + } + + @Test + fun persistAlarmDiscarded_does_not_delete_records_when_user_setting_update_fails() { + whenever(userSettingInfoRepository.updateUserSettingInfo(any())).thenReturn(false) + + sut.persistAlarmDiscarded("alarm-1") + + verify(infusionInfoRepository, never()).deleteInfusionInfo() + verify(patchInfoRepository, never()).deletePatchInfo() + } + + @Test + fun persistAlarmDiscarded_returns_false_when_infusion_delete_fails() { + whenever(infusionInfoRepository.deleteInfusionInfo()).thenReturn(false) + + assertThat(sut.persistAlarmDiscarded("alarm-1")).isFalse() + } + + @Test + fun persistAlarmDiscarded_does_not_delete_patch_when_infusion_delete_fails() { + whenever(infusionInfoRepository.deleteInfusionInfo()).thenReturn(false) + + sut.persistAlarmDiscarded("alarm-1") + + verify(patchInfoRepository, never()).deletePatchInfo() + } + + @Test + fun persistAlarmDiscarded_returns_false_when_patch_delete_fails() { + whenever(patchInfoRepository.deletePatchInfo()).thenReturn(false) + + assertThat(sut.persistAlarmDiscarded("alarm-1")).isFalse() + + // The alarm ack and the earlier writes already ran: the failure is reported, not rolled back. + verify(alarmRepository).removeAlarm(eq("alarm-1")) + verify(infusionInfoRepository).deleteInfusionInfo() + } + + @Test + fun persistAlarmDiscarded_returns_false_when_a_repository_throws() { + whenever(infusionInfoRepository.deleteInfusionInfo()).thenThrow(IllegalStateException("boom")) + + assertThat(sut.persistAlarmDiscarded("alarm-1")).isFalse() + } + + @Test + fun persistAlarmDiscarded_acks_the_requested_alarm_id() { + sut.persistAlarmDiscarded("alarm-42") + + verify(alarmRepository).removeAlarm(eq("alarm-42")) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/AlarmClearRequestUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/AlarmClearRequestUseCaseTest.kt new file mode 100644 index 000000000000..384181cf1a32 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/AlarmClearRequestUseCaseTest.kt @@ -0,0 +1,154 @@ +package app.aaps.pump.carelevo.domain.usecase.alarm + +import app.aaps.pump.carelevo.domain.repository.CarelevoAlarmInfoRepository +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Completable +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.io.IOException + +/** + * Covers the wire-byte mapping and the persist-on-success seam of [AlarmClearRequestUseCase]. + * The store delegation itself lives in `CarelevoAlarmInfoUseCaseTest`. + */ +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class AlarmClearRequestUseCaseTest { + + @Mock lateinit var alarmRepository: CarelevoAlarmInfoRepository + + private lateinit var sut: AlarmClearRequestUseCase + + @BeforeEach + fun setUp() { + sut = AlarmClearRequestUseCase(alarmRepository) + } + + // ---------- commandAlarmType ---------- + + @Test + fun commandAlarmType_maps_alert_cause_to_wire_byte_162() { + assertThat(sut.commandAlarmType(AlarmCause.ALARM_ALERT_OUT_OF_INSULIN)).isEqualTo(162) + } + + @Test + fun commandAlarmType_maps_notice_cause_to_wire_byte_163() { + assertThat(sut.commandAlarmType(AlarmCause.ALARM_NOTICE_LOW_INSULIN)).isEqualTo(163) + } + + @Test + fun commandAlarmType_maps_every_alert_cause_to_162() { + val alertCauses = AlarmCause.entries.filter { it.alarmType == AlarmType.ALERT } + + assertThat(alertCauses).isNotEmpty() + alertCauses.forEach { cause -> + assertThat(sut.commandAlarmType(cause)).isEqualTo(162) + } + } + + @Test + fun commandAlarmType_maps_every_notice_cause_to_163() { + val noticeCauses = AlarmCause.entries.filter { it.alarmType == AlarmType.NOTICE } + + assertThat(noticeCauses).isNotEmpty() + noticeCauses.forEach { cause -> + assertThat(sut.commandAlarmType(cause)).isEqualTo(163) + } + } + + @Test + fun commandAlarmType_rejects_warning_cause() { + // WARNING is a pump-fault tier that is never cleared over this command. + val exception = assertThrows(IllegalArgumentException::class.java) { + sut.commandAlarmType(AlarmCause.ALARM_WARNING_LOW_INSULIN) + } + + assertThat(exception).hasMessageThat().isEqualTo("alarmType is not supported") + } + + @Test + fun commandAlarmType_rejects_every_warning_cause() { + val warningCauses = AlarmCause.entries.filter { it.alarmType == AlarmType.WARNING } + + assertThat(warningCauses).isNotEmpty() + warningCauses.forEach { cause -> + assertThrows(IllegalArgumentException::class.java) { sut.commandAlarmType(cause) } + } + } + + @Test + fun commandAlarmType_rejects_unknown_cause() { + assertThrows(IllegalArgumentException::class.java) { + sut.commandAlarmType(AlarmCause.ALARM_UNKNOWN) + } + } + + @Test + fun commandAlarmType_does_not_touch_the_alarm_store() { + sut.commandAlarmType(AlarmCause.ALARM_ALERT_LOW_BATTERY) + sut.commandAlarmType(AlarmCause.ALARM_NOTICE_BG_CHECK) + + verifyNoMoreInteractions(alarmRepository) + } + + // ---------- persistAlarmCleared ---------- + + @Test + fun persistAlarmCleared_removes_alarm_and_returns_true() { + whenever(alarmRepository.removeAlarm(eq("alarm-1"))).thenReturn(Completable.complete()) + + assertThat(sut.persistAlarmCleared("alarm-1")).isTrue() + + verify(alarmRepository).removeAlarm(eq("alarm-1")) + } + + @Test + fun persistAlarmCleared_returns_false_when_removal_errors() { + whenever(alarmRepository.removeAlarm(eq("alarm-1"))) + .thenReturn(Completable.error(RuntimeException("db down"))) + + assertThat(sut.persistAlarmCleared("alarm-1")).isFalse() + + verify(alarmRepository).removeAlarm(eq("alarm-1")) + } + + @Test + fun persistAlarmCleared_returns_false_when_repository_throws_synchronously() { + whenever(alarmRepository.removeAlarm(eq("alarm-1"))).thenThrow(IllegalStateException("boom")) + + assertThat(sut.persistAlarmCleared("alarm-1")).isFalse() + } + + @Test + fun persistAlarmCleared_returns_false_on_checked_style_error_without_rethrowing() { + whenever(alarmRepository.removeAlarm(eq("alarm-1"))) + .thenReturn(Completable.error(IOException("io"))) + + // runCatching swallows the wrapped checked exception rather than escaping to the caller. + assertThat(sut.persistAlarmCleared("alarm-1")).isFalse() + } + + @Test + fun persistAlarmCleared_only_removes_the_requested_alarm() { + whenever(alarmRepository.removeAlarm(eq("alarm-7"))).thenReturn(Completable.complete()) + + sut.persistAlarmCleared("alarm-7") + + verify(alarmRepository).removeAlarm(eq("alarm-7")) + verify(alarmRepository, never()).clearAlarms() + verifyNoMoreInteractions(alarmRepository) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/CarelevoAlarmInfoUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/CarelevoAlarmInfoUseCaseTest.kt new file mode 100644 index 000000000000..bae42fe0e218 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/CarelevoAlarmInfoUseCaseTest.kt @@ -0,0 +1,83 @@ +package app.aaps.pump.carelevo.domain.usecase.alarm + +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.repository.CarelevoAlarmInfoRepository +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.Optional + +internal class CarelevoAlarmInfoUseCaseTest { + + private val repository: CarelevoAlarmInfoRepository = mock() + private val sut = CarelevoAlarmInfoUseCase(repository) + + @Test + fun observeAlarms_returns_repository_stream() { + val alarms = listOf(sampleAlarm()) + whenever(repository.observeAlarms()).thenReturn(Observable.just(Optional.of(alarms))) + + val result = sut.observeAlarms().blockingFirst() + + assertThat(result.get()).hasSize(1) + verify(repository).observeAlarms() + } + + @Test + fun getAlarmsOnce_delegates_to_repository() { + whenever(repository.getAlarmsOnce()).thenReturn(Single.just(Optional.of(emptyList()))) + + val result = sut.getAlarmsOnce().blockingGet() + + assertThat(result.isPresent).isTrue() + verify(repository).getAlarmsOnce() + } + + @Test + fun upsertAlarm_delegates_to_repository() { + val alarm = sampleAlarm() + whenever(repository.upsertAlarm(alarm)).thenReturn(Completable.complete()) + + sut.upsertAlarm(alarm).blockingAwait() + + verify(repository).upsertAlarm(alarm) + } + + @Test + fun acknowledgeAlarm_removes_alarm_from_store() { + whenever(repository.removeAlarm(any())).thenReturn(Completable.complete()) + + sut.acknowledgeAlarm("alarm-1").blockingAwait() + + verify(repository).removeAlarm(eq("alarm-1")) + } + + @Test + fun clearAlarms_delegates_to_repository() { + whenever(repository.clearAlarms()).thenReturn(Completable.complete()) + + sut.clearAlarms().blockingAwait() + + verify(repository).clearAlarms() + } + + private fun sampleAlarm(): CarelevoAlarmInfo = + CarelevoAlarmInfo( + alarmId = "alarm-1", + alarmType = AlarmType.ALERT, + cause = AlarmCause.ALARM_ALERT_LOW_BATTERY, + value = 3, + createdAt = "2026-03-09 09:00:00", + updatedAt = "2026-03-09 09:00:00", + isAcknowledged = false + ) +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/model/AlarmClearUseCaseRequestTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/model/AlarmClearUseCaseRequestTest.kt new file mode 100644 index 000000000000..d852a660dcbc --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/alarm/model/AlarmClearUseCaseRequestTest.kt @@ -0,0 +1,95 @@ +package app.aaps.pump.carelevo.domain.usecase.alarm.model + +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseRequest +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** Value semantics of the [AlarmClearUseCaseRequest] carrier: defaults, copy and equality. */ +internal class AlarmClearUseCaseRequestTest { + + private fun request(): AlarmClearUseCaseRequest = + AlarmClearUseCaseRequest( + alarmId = "alarm-1", + alarmType = AlarmType.ALERT, + alarmCause = AlarmCause.ALARM_ALERT_OUT_OF_INSULIN + ) + + @Test + fun optional_resume_and_address_fields_default_to_null() { + with(request()) { + assertThat(address).isNull() + assertThat(resumeType).isNull() + assertThat(resumeMode).isNull() + } + } + + @Test + fun required_fields_are_carried_verbatim() { + with(request()) { + assertThat(alarmId).isEqualTo("alarm-1") + assertThat(alarmType).isEqualTo(AlarmType.ALERT) + assertThat(alarmCause).isEqualTo(AlarmCause.ALARM_ALERT_OUT_OF_INSULIN) + } + } + + @Test + fun resume_fields_are_carried_when_supplied() { + val sut = AlarmClearUseCaseRequest( + alarmId = "alarm-2", + alarmType = AlarmType.NOTICE, + alarmCause = AlarmCause.ALARM_NOTICE_LGS_START, + address = "AA:BB:CC:DD:EE:FF", + resumeType = 1, + resumeMode = 2 + ) + + with(sut) { + assertThat(address).isEqualTo("AA:BB:CC:DD:EE:FF") + assertThat(resumeType).isEqualTo(1) + assertThat(resumeMode).isEqualTo(2) + } + } + + @Test + fun is_a_use_case_request() { + assertThat(request()).isInstanceOf(CarelevoUseCaseRequest::class.java) + } + + @Test + fun equal_payloads_are_equal_and_share_a_hash_code() { + assertThat(request()).isEqualTo(request()) + assertThat(request().hashCode()).isEqualTo(request().hashCode()) + } + + @Test + fun requests_differing_in_alarm_id_are_not_equal() { + assertThat(request().copy(alarmId = "alarm-9")).isNotEqualTo(request()) + } + + @Test + fun requests_differing_in_resume_mode_are_not_equal() { + assertThat(request().copy(resumeMode = 4)).isNotEqualTo(request()) + } + + @Test + fun copy_overrides_only_the_named_field() { + val copy = request().copy(address = "11:22:33:44:55:66") + + with(copy) { + assertThat(address).isEqualTo("11:22:33:44:55:66") + assertThat(alarmId).isEqualTo("alarm-1") + assertThat(alarmType).isEqualTo(AlarmType.ALERT) + assertThat(alarmCause).isEqualTo(AlarmCause.ALARM_ALERT_OUT_OF_INSULIN) + assertThat(resumeType).isNull() + assertThat(resumeMode).isNull() + } + } + + @Test + fun toString_exposes_the_alarm_identity() { + assertThat(request().toString()).contains("alarm-1") + assertThat(request().toString()).contains("ALARM_ALERT_OUT_OF_INSULIN") + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/CarelevoCancelTempBasalInfusionUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/CarelevoCancelTempBasalInfusionUseCaseTest.kt new file mode 100644 index 000000000000..9ce945757c05 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/CarelevoCancelTempBasalInfusionUseCaseTest.kt @@ -0,0 +1,166 @@ +package app.aaps.pump.carelevo.domain.usecase.basal + +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoExtendBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoImmeBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoTempBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import com.google.common.truth.Truth.assertThat +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoCancelTempBasalInfusionUseCaseTest { + + private val patchInfoRepository: CarelevoPatchInfoRepository = mock() + private val infusionInfoRepository: CarelevoInfusionInfoRepository = mock() + private val sut = CarelevoCancelTempBasalInfusionUseCase(patchInfoRepository, infusionInfoRepository) + + private fun patchInfo(): CarelevoPatchInfoDomainModel = + CarelevoPatchInfoDomainModel(address = "AA:BB:CC:DD:EE:FF", createdAt = DateTime.now(), updatedAt = DateTime.now(), mode = 2) + + private fun basalInfusion(isStop: Boolean): CarelevoBasalInfusionInfoDomainModel = + CarelevoBasalInfusionInfoDomainModel(infusionId = "b", address = "AA", mode = 1, segments = emptyList(), isStop = isStop) + + private fun tempBasalInfusion(): CarelevoTempBasalInfusionInfoDomainModel = + CarelevoTempBasalInfusionInfoDomainModel(infusionId = "t", address = "AA", mode = 2) + + private fun immeBolusInfusion(): CarelevoImmeBolusInfusionInfoDomainModel = + CarelevoImmeBolusInfusionInfoDomainModel(infusionId = "i", address = "AA", mode = 3) + + private fun extendBolusInfusion(): CarelevoExtendBolusInfusionInfoDomainModel = + CarelevoExtendBolusInfusionInfoDomainModel(infusionId = "e", address = "AA", mode = 5) + + private fun capturedMode(): Int { + val captor = argumentCaptor() + verify(patchInfoRepository).updatePatchInfo(captor.capture()) + return captor.firstValue.mode!! + } + + // ---------- guard / failure branches ---------- + + @Test + fun `returns false when delete temp basal fails and never reads infusion info`() { + whenever(infusionInfoRepository.deleteTempBasalInfusionInfo()).thenReturn(false) + + assertThat(sut.persistTempBasalCancelled()).isFalse() + verify(infusionInfoRepository, never()).getInfusionInfoBySync() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `returns false when infusion info is missing`() { + whenever(infusionInfoRepository.deleteTempBasalInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(null) + + assertThat(sut.persistTempBasalCancelled()).isFalse() + verify(patchInfoRepository, never()).getPatchInfoBySync() + } + + @Test + fun `returns false when derived mode is null because no infusion records remain`() { + whenever(infusionInfoRepository.deleteTempBasalInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel()) + + assertThat(sut.persistTempBasalCancelled()).isFalse() + verify(patchInfoRepository, never()).getPatchInfoBySync() + } + + @Test + fun `returns false when patch info is missing`() { + whenever(infusionInfoRepository.deleteTempBasalInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = false))) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(null) + + assertThat(sut.persistTempBasalCancelled()).isFalse() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `returns false when patch update fails`() { + whenever(infusionInfoRepository.deleteTempBasalInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = false))) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(false) + + assertThat(sut.persistTempBasalCancelled()).isFalse() + } + + @Test + fun `returns false when delete throws`() { + whenever(infusionInfoRepository.deleteTempBasalInfusionInfo()).thenThrow(RuntimeException("boom")) + + assertThat(sut.persistTempBasalCancelled()).isFalse() + } + + // ---------- success + derivePatchMode branches ---------- + + @Test + fun `success derives BASAL_RUNNING mode 1 from a running basal`() { + whenever(infusionInfoRepository.deleteTempBasalInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = false))) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistTempBasalCancelled()).isTrue() + assertThat(capturedMode()).isEqualTo(1) + } + + @Test + fun `success derives BASAL_STOPPED mode 0 from a stopped basal`() { + whenever(infusionInfoRepository.deleteTempBasalInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = true))) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistTempBasalCancelled()).isTrue() + assertThat(capturedMode()).isEqualTo(0) + } + + @Test + fun `success derives TEMP_BASAL mode 2 when a temp basal is still present`() { + whenever(infusionInfoRepository.deleteTempBasalInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(tempBasalInfusionInfo = tempBasalInfusion())) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistTempBasalCancelled()).isTrue() + assertThat(capturedMode()).isEqualTo(2) + } + + @Test + fun `success derives IMME_BOLUS mode 3 when an immediate bolus is present`() { + whenever(infusionInfoRepository.deleteTempBasalInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(immeBolusInfusionInfo = immeBolusInfusion())) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistTempBasalCancelled()).isTrue() + assertThat(capturedMode()).isEqualTo(3) + } + + @Test + fun `success derives EXTEND_BOLUS mode 5 when an extended bolus is present`() { + whenever(infusionInfoRepository.deleteTempBasalInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(extendBolusInfusionInfo = extendBolusInfusion())) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistTempBasalCancelled()).isTrue() + assertThat(capturedMode()).isEqualTo(5) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/CarelevoSetBasalProgramUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/CarelevoSetBasalProgramUseCaseTest.kt new file mode 100644 index 000000000000..7beae4a92e88 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/CarelevoSetBasalProgramUseCaseTest.kt @@ -0,0 +1,155 @@ +package app.aaps.pump.carelevo.domain.usecase.basal + +import app.aaps.core.interfaces.profile.Profile +import app.aaps.pump.carelevo.domain.model.basal.CarelevoBasalSegmentDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import com.google.common.truth.Truth.assertThat +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoSetBasalProgramUseCaseTest { + + private val patchInfoRepository: CarelevoPatchInfoRepository = mock() + private val infusionInfoRepository: CarelevoInfusionInfoRepository = mock() + private val sut = CarelevoSetBasalProgramUseCase(patchInfoRepository, infusionInfoRepository) + + private fun patchInfo(address: String = "AA:BB:CC:DD:EE:FF"): CarelevoPatchInfoDomainModel = + CarelevoPatchInfoDomainModel( + address = address, + createdAt = DateTime.now().minusHours(1), + updatedAt = DateTime.now(), + mode = 0 + ) + + private fun profileWith(vararg values: Profile.ProfileValue): Profile = mock().also { + whenever(it.getBasalValues()).thenReturn(arrayOf(*values)) + } + + // ---------- buildBasalProgramPlan ---------- + + @Test + fun `buildBasalProgramPlan produces 24 segments and three eight-slot programs for a single segment`() { + val profile = profileWith(Profile.ProfileValue(0, 1.0)) + + val plan = sut.buildBasalProgramPlan(profile) + + assertThat(plan.segments).hasSize(24) + assertThat(plan.programs).hasSize(3) + plan.programs.forEach { assertThat(it).hasSize(8) } + assertThat(plan.programs[0]).containsExactly(1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0) + assertThat(plan.segments.map { it.speed }.distinct()).containsExactly(1.0) + } + + @Test + fun `buildBasalProgramPlan maps a two-segment profile across the chunk boundary`() { + // 0-12h at 1.0, 12-24h at 2.0 (43200s = 720min = 12h). + val profile = profileWith( + Profile.ProfileValue(0, 1.0), + Profile.ProfileValue(43_200, 2.0) + ) + + val plan = sut.buildBasalProgramPlan(profile) + + assertThat(plan.segments).hasSize(24) + // hours 0..11 -> 1.0, hours 12..23 -> 2.0 + assertThat(plan.segments[0].speed).isEqualTo(1.0) + assertThat(plan.segments[11].speed).isEqualTo(1.0) + assertThat(plan.segments[12].speed).isEqualTo(2.0) + assertThat(plan.segments[23].speed).isEqualTo(2.0) + // chunk boundary at index 8..15 (program 1): first four 1.0, last four 2.0 + assertThat(plan.programs[0]).containsExactly(1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0).inOrder() + assertThat(plan.programs[1]).containsExactly(1.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 2.0).inOrder() + assertThat(plan.programs[2]).containsExactly(2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0).inOrder() + } + + @Test + fun `buildBasalProgramPlan yields zero-speed segments for an empty profile`() { + val profile = profileWith() + + val plan = sut.buildBasalProgramPlan(profile) + + assertThat(plan.segments).hasSize(24) + assertThat(plan.programs).hasSize(3) + assertThat(plan.segments.map { it.speed }.distinct()).containsExactly(0.0) + assertThat(plan.programs[0]).containsExactly(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) + } + + // ---------- persistBasalProgram ---------- + + @Test + fun `persistBasalProgram returns false when no patch record exists`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(null) + + assertThat(sut.persistBasalProgram(emptyList())).isFalse() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + verify(infusionInfoRepository, never()).updateBasalInfusionInfo(any()) + } + + @Test + fun `persistBasalProgram returns false when patch update fails`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(false) + + assertThat(sut.persistBasalProgram(emptyList())).isFalse() + verify(infusionInfoRepository, never()).updateBasalInfusionInfo(any()) + } + + @Test + fun `persistBasalProgram returns false when basal infusion update fails`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + whenever(infusionInfoRepository.updateBasalInfusionInfo(any())).thenReturn(false) + + assertThat(sut.persistBasalProgram(emptyList())).isFalse() + } + + @Test + fun `persistBasalProgram returns true and persists mode 1 with mapped segments`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo("11:22:33:44:55:66")) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + whenever(infusionInfoRepository.updateBasalInfusionInfo(any())).thenReturn(true) + + val segments = listOf(CarelevoBasalSegmentDomainModel(startTime = 60, endTime = 120, speed = 1.5)) + assertThat(sut.persistBasalProgram(segments)).isTrue() + + val patchCaptor = argumentCaptor() + verify(patchInfoRepository).updatePatchInfo(patchCaptor.capture()) + assertThat(patchCaptor.firstValue.mode).isEqualTo(1) + assertThat(patchCaptor.firstValue.address).isEqualTo("11:22:33:44:55:66") + + val infusionCaptor = argumentCaptor() + verify(infusionInfoRepository).updateBasalInfusionInfo(infusionCaptor.capture()) + val persisted = infusionCaptor.firstValue + assertThat(persisted.address).isEqualTo("11:22:33:44:55:66") + assertThat(persisted.mode).isEqualTo(1) + assertThat(persisted.isStop).isFalse() + assertThat(persisted.segments).hasSize(1) + assertThat(persisted.segments[0].startTime).isEqualTo(60) + assertThat(persisted.segments[0].endTime).isEqualTo(120) + assertThat(persisted.segments[0].speed).isEqualTo(1.5) + } + + @Test + fun `persistBasalProgram returns false when updateBasalInfusionInfo throws`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + whenever(infusionInfoRepository.updateBasalInfusionInfo(any())).thenThrow(RuntimeException("boom")) + + assertThat(sut.persistBasalProgram(emptyList())).isFalse() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/CarelevoStartTempBasalInfusionUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/CarelevoStartTempBasalInfusionUseCaseTest.kt new file mode 100644 index 000000000000..52d380a11c58 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/basal/CarelevoStartTempBasalInfusionUseCaseTest.kt @@ -0,0 +1,117 @@ +package app.aaps.pump.carelevo.domain.usecase.basal + +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoTempBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import app.aaps.pump.carelevo.domain.usecase.basal.model.StartTempBasalInfusionRequestModel +import com.google.common.truth.Truth.assertThat +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoStartTempBasalInfusionUseCaseTest { + + private val patchInfoRepository: CarelevoPatchInfoRepository = mock() + private val infusionInfoRepository: CarelevoInfusionInfoRepository = mock() + private val sut = CarelevoStartTempBasalInfusionUseCase(patchInfoRepository, infusionInfoRepository) + + private fun patchInfo(address: String = "AA:BB:CC:DD:EE:FF"): CarelevoPatchInfoDomainModel = + CarelevoPatchInfoDomainModel(address = address, createdAt = DateTime.now(), updatedAt = DateTime.now(), mode = 1) + + private fun request( + isUnit: Boolean = false, + speed: Double? = 1.5, + percent: Int? = 50, + minutes: Int = 30 + ) = StartTempBasalInfusionRequestModel(isUnit = isUnit, speed = speed, percent = percent, minutes = minutes) + + // ---------- guard / failure branches ---------- + + @Test + fun `returns false when no patch record exists and never writes infusion info`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(null) + + assertThat(sut.persistTempBasalStarted(request())).isFalse() + verify(infusionInfoRepository, never()).updateTempBasalInfusionInfo(any()) + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `returns false when temp basal infusion update fails and never updates patch`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(infusionInfoRepository.updateTempBasalInfusionInfo(any())).thenReturn(false) + + assertThat(sut.persistTempBasalStarted(request())).isFalse() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `returns false when patch update fails`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(infusionInfoRepository.updateTempBasalInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(false) + + assertThat(sut.persistTempBasalStarted(request())).isFalse() + } + + @Test + fun `returns false when infusion update throws`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(infusionInfoRepository.updateTempBasalInfusionInfo(any())).thenThrow(RuntimeException("boom")) + + assertThat(sut.persistTempBasalStarted(request())).isFalse() + } + + // ---------- success ---------- + + @Test + fun `success persists mode 2 temp basal info and patch mode 2`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo("11:22:33:44:55:66")) + whenever(infusionInfoRepository.updateTempBasalInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistTempBasalStarted(request(isUnit = false, speed = 2.0, percent = 75, minutes = 45))).isTrue() + + val infusionCaptor = argumentCaptor() + verify(infusionInfoRepository).updateTempBasalInfusionInfo(infusionCaptor.capture()) + val persisted = infusionCaptor.firstValue + assertThat(persisted.address).isEqualTo("11:22:33:44:55:66") + assertThat(persisted.mode).isEqualTo(2) + assertThat(persisted.percent).isEqualTo(75) + assertThat(persisted.speed).isEqualTo(2.0) + assertThat(persisted.infusionDurationMin).isEqualTo(45) + + val patchCaptor = argumentCaptor() + verify(patchInfoRepository).updatePatchInfo(patchCaptor.capture()) + assertThat(patchCaptor.firstValue.mode).isEqualTo(2) + } + + @Test + fun `success passes through null percent and null speed`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(infusionInfoRepository.updateTempBasalInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistTempBasalStarted(request(isUnit = true, speed = null, percent = null, minutes = 60))).isTrue() + + val infusionCaptor = argumentCaptor() + verify(infusionInfoRepository).updateTempBasalInfusionInfo(infusionCaptor.capture()) + val persisted = infusionCaptor.firstValue + assertThat(persisted.percent).isNull() + assertThat(persisted.speed).isNull() + assertThat(persisted.infusionDurationMin).isEqualTo(60) + assertThat(persisted.mode).isEqualTo(2) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoCancelExtendBolusInfusionUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoCancelExtendBolusInfusionUseCaseTest.kt new file mode 100644 index 000000000000..3d4dbeeaae25 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoCancelExtendBolusInfusionUseCaseTest.kt @@ -0,0 +1,201 @@ +package app.aaps.pump.carelevo.domain.usecase.bolus + +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoExtendBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoImmeBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoTempBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import com.google.common.truth.Truth.assertThat +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoCancelExtendBolusInfusionUseCaseTest { + + private val patchInfoRepository: CarelevoPatchInfoRepository = mock() + private val infusionInfoRepository: CarelevoInfusionInfoRepository = mock() + private val sut = CarelevoCancelExtendBolusInfusionUseCase(patchInfoRepository, infusionInfoRepository) + + private fun patchInfo(address: String = "AA:BB:CC:DD:EE:FF"): CarelevoPatchInfoDomainModel = + CarelevoPatchInfoDomainModel(address = address, createdAt = DateTime.now(), updatedAt = DateTime.now(), mode = 5) + + private fun basalInfusion(isStop: Boolean): CarelevoBasalInfusionInfoDomainModel = + CarelevoBasalInfusionInfoDomainModel(infusionId = "b", address = "AA", mode = 1, segments = emptyList(), isStop = isStop) + + private fun tempBasalInfusion(): CarelevoTempBasalInfusionInfoDomainModel = + CarelevoTempBasalInfusionInfoDomainModel(infusionId = "t", address = "AA", mode = 2) + + private fun immeBolusInfusion(): CarelevoImmeBolusInfusionInfoDomainModel = + CarelevoImmeBolusInfusionInfoDomainModel(infusionId = "i", address = "AA", mode = 3) + + private fun extendBolusInfusion(): CarelevoExtendBolusInfusionInfoDomainModel = + CarelevoExtendBolusInfusionInfoDomainModel(infusionId = "e", address = "AA", mode = 5) + + private fun capturedPatch(): CarelevoPatchInfoDomainModel { + val captor = argumentCaptor() + verify(patchInfoRepository).updatePatchInfo(captor.capture()) + return captor.firstValue + } + + // ---------- guard / failure branches ---------- + + @Test + fun `returns false when delete extend bolus fails and never reads infusion info`() { + whenever(infusionInfoRepository.deleteExtendBolusInfusionInfo()).thenReturn(false) + + assertThat(sut.persistExtendBolusCancelled()).isFalse() + verify(infusionInfoRepository, never()).getInfusionInfoBySync() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `returns false when infusion info is missing`() { + whenever(infusionInfoRepository.deleteExtendBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(null) + + assertThat(sut.persistExtendBolusCancelled()).isFalse() + verify(patchInfoRepository, never()).getPatchInfoBySync() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `returns false when derived mode is null because no infusion records remain`() { + whenever(infusionInfoRepository.deleteExtendBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel()) + + assertThat(sut.persistExtendBolusCancelled()).isFalse() + // mode derivation precedes the patch read in this use case + verify(patchInfoRepository, never()).getPatchInfoBySync() + } + + @Test + fun `returns false when patch info is missing`() { + whenever(infusionInfoRepository.deleteExtendBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = false))) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(null) + + assertThat(sut.persistExtendBolusCancelled()).isFalse() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `returns false when patch update fails`() { + whenever(infusionInfoRepository.deleteExtendBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = false))) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(false) + + assertThat(sut.persistExtendBolusCancelled()).isFalse() + } + + @Test + fun `returns false when delete throws`() { + whenever(infusionInfoRepository.deleteExtendBolusInfusionInfo()).thenThrow(RuntimeException("boom")) + + assertThat(sut.persistExtendBolusCancelled()).isFalse() + } + + @Test + fun `returns false when patch read throws`() { + whenever(infusionInfoRepository.deleteExtendBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = false))) + whenever(patchInfoRepository.getPatchInfoBySync()).thenThrow(RuntimeException("boom")) + + assertThat(sut.persistExtendBolusCancelled()).isFalse() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + // ---------- success + derivePatchMode branches ---------- + + @Test + fun `success derives BASAL_RUNNING mode 1 from a running basal`() { + whenever(infusionInfoRepository.deleteExtendBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = false))) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistExtendBolusCancelled()).isTrue() + assertThat(capturedPatch().mode).isEqualTo(1) + } + + @Test + fun `success derives BASAL_STOPPED mode 0 from a stopped basal`() { + whenever(infusionInfoRepository.deleteExtendBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = true))) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistExtendBolusCancelled()).isTrue() + assertThat(capturedPatch().mode).isEqualTo(0) + } + + @Test + fun `success derives TEMP_BASAL mode 2 when a temp basal is still running`() { + whenever(infusionInfoRepository.deleteExtendBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(tempBasalInfusionInfo = tempBasalInfusion())) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistExtendBolusCancelled()).isTrue() + assertThat(capturedPatch().mode).isEqualTo(2) + } + + @Test + fun `success derives IMME_BOLUS mode 3 when an immediate bolus outranks a running temp basal`() { + whenever(infusionInfoRepository.deleteExtendBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn( + CarelevoInfusionInfoDomainModel( + basalInfusionInfo = basalInfusion(isStop = false), + tempBasalInfusionInfo = tempBasalInfusion(), + immeBolusInfusionInfo = immeBolusInfusion() + ) + ) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistExtendBolusCancelled()).isTrue() + assertThat(capturedPatch().mode).isEqualTo(3) + } + + @Test + fun `success derives EXTEND_BOLUS mode 5 when the extend bolus record still remains`() { + whenever(infusionInfoRepository.deleteExtendBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(extendBolusInfusionInfo = extendBolusInfusion())) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistExtendBolusCancelled()).isTrue() + assertThat(capturedPatch().mode).isEqualTo(5) + } + + @Test + fun `success preserves the patch identity and only rewrites mode and updatedAt`() { + val original = patchInfo("11:22:33:44:55:66").copy(insulinRemain = 90.0, updatedAt = DateTime.now().minusHours(1)) + whenever(infusionInfoRepository.deleteExtendBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = false))) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(original) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistExtendBolusCancelled()).isTrue() + + val persisted = capturedPatch() + assertThat(persisted.address).isEqualTo("11:22:33:44:55:66") + assertThat(persisted.mode).isEqualTo(1) + assertThat(persisted.insulinRemain).isEqualTo(90.0) + assertThat(persisted.updatedAt.millis).isGreaterThan(original.updatedAt.millis) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoCancelImmeBolusInfusionUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoCancelImmeBolusInfusionUseCaseTest.kt new file mode 100644 index 000000000000..3c58baefea93 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoCancelImmeBolusInfusionUseCaseTest.kt @@ -0,0 +1,192 @@ +package app.aaps.pump.carelevo.domain.usecase.bolus + +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoExtendBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoImmeBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoTempBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import com.google.common.truth.Truth.assertThat +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoCancelImmeBolusInfusionUseCaseTest { + + private val patchInfoRepository: CarelevoPatchInfoRepository = mock() + private val infusionInfoRepository: CarelevoInfusionInfoRepository = mock() + private val sut = CarelevoCancelImmeBolusInfusionUseCase(patchInfoRepository, infusionInfoRepository) + + private fun patchInfo(address: String = "AA:BB:CC:DD:EE:FF"): CarelevoPatchInfoDomainModel = + CarelevoPatchInfoDomainModel(address = address, createdAt = DateTime.now(), updatedAt = DateTime.now(), mode = 3) + + private fun basalInfusion(isStop: Boolean): CarelevoBasalInfusionInfoDomainModel = + CarelevoBasalInfusionInfoDomainModel(infusionId = "b", address = "AA", mode = 1, segments = emptyList(), isStop = isStop) + + private fun tempBasalInfusion(): CarelevoTempBasalInfusionInfoDomainModel = + CarelevoTempBasalInfusionInfoDomainModel(infusionId = "t", address = "AA", mode = 2) + + private fun immeBolusInfusion(): CarelevoImmeBolusInfusionInfoDomainModel = + CarelevoImmeBolusInfusionInfoDomainModel(infusionId = "i", address = "AA", mode = 3) + + private fun extendBolusInfusion(): CarelevoExtendBolusInfusionInfoDomainModel = + CarelevoExtendBolusInfusionInfoDomainModel(infusionId = "e", address = "AA", mode = 5) + + private fun capturedPatch(): CarelevoPatchInfoDomainModel { + val captor = argumentCaptor() + verify(patchInfoRepository).updatePatchInfo(captor.capture()) + return captor.firstValue + } + + // ---------- guard / failure branches ---------- + + @Test + fun `returns false when delete imme bolus fails and never reads infusion info`() { + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenReturn(false) + + assertThat(sut.persistImmeBolusCancelled()).isFalse() + verify(infusionInfoRepository, never()).getInfusionInfoBySync() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `returns false when infusion info is missing`() { + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(null) + + assertThat(sut.persistImmeBolusCancelled()).isFalse() + verify(patchInfoRepository, never()).getPatchInfoBySync() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `returns false when derived mode is null because no infusion records remain`() { + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel()) + + assertThat(sut.persistImmeBolusCancelled()).isFalse() + // mode derivation precedes the patch read in this use case + verify(patchInfoRepository, never()).getPatchInfoBySync() + } + + @Test + fun `returns false when patch info is missing`() { + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = false))) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(null) + + assertThat(sut.persistImmeBolusCancelled()).isFalse() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `returns false when patch update fails`() { + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = false))) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(false) + + assertThat(sut.persistImmeBolusCancelled()).isFalse() + } + + @Test + fun `returns false when delete throws`() { + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenThrow(RuntimeException("boom")) + + assertThat(sut.persistImmeBolusCancelled()).isFalse() + } + + @Test + fun `returns false when patch update throws`() { + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = false))) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenThrow(RuntimeException("boom")) + + assertThat(sut.persistImmeBolusCancelled()).isFalse() + } + + // ---------- success + derivePatchMode branches ---------- + + @Test + fun `success derives BASAL_RUNNING mode 1 from a running basal`() { + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = false))) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistImmeBolusCancelled()).isTrue() + assertThat(capturedPatch().mode).isEqualTo(1) + } + + @Test + fun `success derives BASAL_STOPPED mode 0 from a stopped basal`() { + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = true))) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistImmeBolusCancelled()).isTrue() + assertThat(capturedPatch().mode).isEqualTo(0) + } + + @Test + fun `success derives TEMP_BASAL mode 2 when a temp basal is still running`() { + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(tempBasalInfusionInfo = tempBasalInfusion())) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistImmeBolusCancelled()).isTrue() + assertThat(capturedPatch().mode).isEqualTo(2) + } + + @Test + fun `success derives EXTEND_BOLUS mode 5 when an extended bolus outranks a leftover imme bolus`() { + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn( + CarelevoInfusionInfoDomainModel( + basalInfusionInfo = basalInfusion(isStop = false), + immeBolusInfusionInfo = immeBolusInfusion(), + extendBolusInfusionInfo = extendBolusInfusion() + ) + ) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistImmeBolusCancelled()).isTrue() + assertThat(capturedPatch().mode).isEqualTo(5) + } + + @Test + fun `success preserves the patch identity and only rewrites mode and updatedAt`() { + val original = patchInfo("11:22:33:44:55:66").copy(bolusActionSeq = 7, insulinRemain = 120.0, updatedAt = DateTime.now().minusHours(1)) + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = false))) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(original) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistImmeBolusCancelled()).isTrue() + + val persisted = capturedPatch() + assertThat(persisted.address).isEqualTo("11:22:33:44:55:66") + assertThat(persisted.mode).isEqualTo(1) + // bolusActionSeq is deliberately NOT cleared by the cancel persist + assertThat(persisted.bolusActionSeq).isEqualTo(7) + assertThat(persisted.insulinRemain).isEqualTo(120.0) + assertThat(persisted.updatedAt.millis).isGreaterThan(original.updatedAt.millis) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoFinishImmeBolusInfusionUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoFinishImmeBolusInfusionUseCaseTest.kt new file mode 100644 index 000000000000..8c9f88a077b3 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoFinishImmeBolusInfusionUseCaseTest.kt @@ -0,0 +1,256 @@ +package app.aaps.pump.carelevo.domain.usecase.bolus + +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoBasalSegmentInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoExtendBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoImmeBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoTempBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.model.result.ResultSuccess +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import com.google.common.truth.Truth.assertThat +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +internal class CarelevoFinishImmeBolusInfusionUseCaseTest { + + private val patchInfoRepository: CarelevoPatchInfoRepository = mock() + private val infusionInfoRepository: CarelevoInfusionInfoRepository = mock() + + private val sut = CarelevoFinishImmeBolusInfusionUseCase( + patchInfoRepository, + infusionInfoRepository + ) + + @Test + fun execute_returns_success_when_cleanup_succeeds() { + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn( + CarelevoInfusionInfoDomainModel( + basalInfusionInfo = CarelevoBasalInfusionInfoDomainModel( + infusionId = "basal-1", + address = "AA:BB", + mode = 1, + segments = listOf(CarelevoBasalSegmentInfusionInfoDomainModel(startTime = 0, endTime = 1, speed = 1.0)), + isStop = false + ) + ) + ) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(CarelevoPatchInfoDomainModel("AA:BB", DateTime.now(), DateTime.now(), mode = 1)) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + val result = sut.execute().blockingGet() + + assertThat(result).isInstanceOf(ResponseResult.Success::class.java) + } + + // ---------- helpers ---------- + + private fun patchInfo(address: String = "AA:BB"): CarelevoPatchInfoDomainModel = + CarelevoPatchInfoDomainModel(address, DateTime.now(), DateTime.now(), mode = 3) + + private fun basalInfusion(isStop: Boolean): CarelevoBasalInfusionInfoDomainModel = + CarelevoBasalInfusionInfoDomainModel( + infusionId = "basal-1", + address = "AA:BB", + mode = 1, + segments = listOf(CarelevoBasalSegmentInfusionInfoDomainModel(startTime = 0, endTime = 1, speed = 1.0)), + isStop = isStop + ) + + /** Stub the whole happy path, leaving the caller to override the one step under test. */ + private fun stubHappyPath(infusionInfo: CarelevoInfusionInfoDomainModel = CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = false))) { + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(infusionInfo) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + } + + private fun capturedPatch(): CarelevoPatchInfoDomainModel { + val captor = argumentCaptor() + verify(patchInfoRepository).updatePatchInfo(captor.capture()) + return captor.firstValue + } + + private fun executeExpectingError(): Throwable { + val result = sut.execute().blockingGet() + assertThat(result).isInstanceOf(ResponseResult.Error::class.java) + return (result as ResponseResult.Error).e + } + + // ---------- failure branches ---------- + + @Test + fun execute_returns_error_when_delete_imme_bolus_fails_and_never_reads_infusion_info() { + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenReturn(false) + + assertThat(executeExpectingError()).isInstanceOf(IllegalStateException::class.java) + verify(infusionInfoRepository, never()).getInfusionInfoBySync() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun execute_returns_error_when_infusion_info_is_missing() { + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(null) + + assertThat(executeExpectingError()).isInstanceOf(NullPointerException::class.java) + verify(patchInfoRepository, never()).getPatchInfoBySync() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun execute_returns_error_when_patch_info_is_missing() { + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = false))) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(null) + + assertThat(executeExpectingError()).isInstanceOf(NullPointerException::class.java) + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun execute_returns_error_when_derived_mode_is_null_because_no_infusion_records_remain() { + stubHappyPath(infusionInfo = CarelevoInfusionInfoDomainModel()) + + assertThat(executeExpectingError()).isInstanceOf(NullPointerException::class.java) + // this use case reads the patch BEFORE deriving the mode, so the read still happened + verify(patchInfoRepository).getPatchInfoBySync() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun execute_returns_error_when_patch_update_fails() { + stubHappyPath() + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(false) + + assertThat(executeExpectingError()).isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun execute_returns_error_when_delete_throws() { + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenThrow(RuntimeException("boom")) + + assertThat(executeExpectingError()).hasMessageThat().isEqualTo("boom") + } + + @Test + fun execute_returns_error_when_patch_update_throws() { + stubHappyPath() + whenever(patchInfoRepository.updatePatchInfo(any())).thenThrow(RuntimeException("boom")) + + assertThat(executeExpectingError()).hasMessageThat().isEqualTo("boom") + } + + // ---------- success payload + derivePatchMode branches ---------- + + @Test + fun execute_success_carries_ResultSuccess_as_the_payload() { + stubHappyPath() + + val result = sut.execute().blockingGet() + + assertThat(result).isInstanceOf(ResponseResult.Success::class.java) + assertThat((result as ResponseResult.Success).data).isSameInstanceAs(ResultSuccess) + } + + @Test + fun execute_success_derives_BASAL_RUNNING_mode_1_from_a_running_basal() { + stubHappyPath(infusionInfo = CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = false))) + + assertThat(sut.execute().blockingGet()).isInstanceOf(ResponseResult.Success::class.java) + assertThat(capturedPatch().mode).isEqualTo(1) + } + + @Test + fun execute_success_derives_BASAL_STOPPED_mode_0_from_a_stopped_basal() { + stubHappyPath(infusionInfo = CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = true))) + + assertThat(sut.execute().blockingGet()).isInstanceOf(ResponseResult.Success::class.java) + assertThat(capturedPatch().mode).isEqualTo(0) + } + + @Test + fun execute_success_derives_TEMP_BASAL_mode_2_when_a_temp_basal_is_still_running() { + stubHappyPath( + infusionInfo = CarelevoInfusionInfoDomainModel( + tempBasalInfusionInfo = CarelevoTempBasalInfusionInfoDomainModel(infusionId = "t", address = "AA:BB", mode = 2) + ) + ) + + assertThat(sut.execute().blockingGet()).isInstanceOf(ResponseResult.Success::class.java) + assertThat(capturedPatch().mode).isEqualTo(2) + } + + @Test + fun execute_success_derives_IMME_BOLUS_mode_3_when_an_imme_bolus_record_still_remains() { + stubHappyPath( + infusionInfo = CarelevoInfusionInfoDomainModel( + basalInfusionInfo = basalInfusion(isStop = false), + immeBolusInfusionInfo = CarelevoImmeBolusInfusionInfoDomainModel(infusionId = "i", address = "AA:BB", mode = 3) + ) + ) + + assertThat(sut.execute().blockingGet()).isInstanceOf(ResponseResult.Success::class.java) + assertThat(capturedPatch().mode).isEqualTo(3) + } + + @Test + fun execute_success_derives_EXTEND_BOLUS_mode_5_when_an_extended_bolus_outranks_everything_else() { + stubHappyPath( + infusionInfo = CarelevoInfusionInfoDomainModel( + basalInfusionInfo = basalInfusion(isStop = false), + extendBolusInfusionInfo = CarelevoExtendBolusInfusionInfoDomainModel(infusionId = "e", address = "AA:BB", mode = 5) + ) + ) + + assertThat(sut.execute().blockingGet()).isInstanceOf(ResponseResult.Success::class.java) + assertThat(capturedPatch().mode).isEqualTo(5) + } + + @Test + fun execute_success_preserves_the_patch_identity_and_only_rewrites_mode_and_updatedAt() { + val original = CarelevoPatchInfoDomainModel( + address = "11:22:33:44:55:66", + createdAt = DateTime.now().minusDays(1), + updatedAt = DateTime.now().minusHours(1), + mode = 3, + bolusActionSeq = 5, + insulinRemain = 150.0 + ) + whenever(infusionInfoRepository.deleteImmeBolusInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel(basalInfusionInfo = basalInfusion(isStop = false))) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(original) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.execute().blockingGet()).isInstanceOf(ResponseResult.Success::class.java) + + val persisted = capturedPatch() + assertThat(persisted.address).isEqualTo("11:22:33:44:55:66") + assertThat(persisted.mode).isEqualTo(1) + assertThat(persisted.createdAt.millis).isEqualTo(original.createdAt.millis) + // finishing a bolus does not clear the action seq + assertThat(persisted.bolusActionSeq).isEqualTo(5) + assertThat(persisted.insulinRemain).isEqualTo(150.0) + assertThat(persisted.updatedAt.millis).isGreaterThan(original.updatedAt.millis) + } + + @Test + fun execute_is_cold_and_does_not_touch_the_repositories_until_subscribed() { + stubHappyPath() + + sut.execute() + + verify(infusionInfoRepository, never()).deleteImmeBolusInfusionInfo() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoStartExtendBolusInfusionUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoStartExtendBolusInfusionUseCaseTest.kt new file mode 100644 index 000000000000..0933f2c32f37 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoStartExtendBolusInfusionUseCaseTest.kt @@ -0,0 +1,168 @@ +package app.aaps.pump.carelevo.domain.usecase.bolus + +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoExtendBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import com.google.common.truth.Truth.assertThat +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoStartExtendBolusInfusionUseCaseTest { + + private val patchInfoRepository: CarelevoPatchInfoRepository = mock() + private val infusionInfoRepository: CarelevoInfusionInfoRepository = mock() + private val sut = CarelevoStartExtendBolusInfusionUseCase(patchInfoRepository, infusionInfoRepository) + + private fun patchInfo(address: String = "AA:BB:CC:DD:EE:FF"): CarelevoPatchInfoDomainModel = + CarelevoPatchInfoDomainModel(address = address, createdAt = DateTime.now(), updatedAt = DateTime.now(), mode = 1) + + private fun capturedInfusion(): CarelevoExtendBolusInfusionInfoDomainModel { + val captor = argumentCaptor() + verify(infusionInfoRepository).updateExtendBolusInfusionInfo(captor.capture()) + return captor.firstValue + } + + private fun capturedPatch(): CarelevoPatchInfoDomainModel { + val captor = argumentCaptor() + verify(patchInfoRepository).updatePatchInfo(captor.capture()) + return captor.firstValue + } + + // ---------- guard / failure branches ---------- + + @Test + fun `returns false when no patch record exists and never writes infusion info`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(null) + + assertThat(sut.persistExtendBolusStarted(volume = 2.0, speed = 4.0, minutes = 30)).isFalse() + verify(infusionInfoRepository, never()).updateExtendBolusInfusionInfo(any()) + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `returns false when extend bolus infusion update fails and never updates patch`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(infusionInfoRepository.updateExtendBolusInfusionInfo(any())).thenReturn(false) + + assertThat(sut.persistExtendBolusStarted(volume = 2.0, speed = 4.0, minutes = 30)).isFalse() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `returns false when patch update fails`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(infusionInfoRepository.updateExtendBolusInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(false) + + assertThat(sut.persistExtendBolusStarted(volume = 2.0, speed = 4.0, minutes = 30)).isFalse() + } + + @Test + fun `returns false when patch read throws`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenThrow(RuntimeException("boom")) + + assertThat(sut.persistExtendBolusStarted(volume = 2.0, speed = 4.0, minutes = 30)).isFalse() + verify(infusionInfoRepository, never()).updateExtendBolusInfusionInfo(any()) + } + + @Test + fun `returns false when infusion update throws`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(infusionInfoRepository.updateExtendBolusInfusionInfo(any())).thenThrow(RuntimeException("boom")) + + assertThat(sut.persistExtendBolusStarted(volume = 2.0, speed = 4.0, minutes = 30)).isFalse() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + // ---------- success ---------- + + @Test + fun `success persists mode 5 extend bolus info carrying volume speed and duration`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo("11:22:33:44:55:66")) + whenever(infusionInfoRepository.updateExtendBolusInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistExtendBolusStarted(volume = 3.0, speed = 6.0, minutes = 30)).isTrue() + + val persisted = capturedInfusion() + assertThat(persisted.address).isEqualTo("11:22:33:44:55:66") + assertThat(persisted.mode).isEqualTo(5) + assertThat(persisted.volume).isEqualTo(3.0) + assertThat(persisted.speed).isEqualTo(6.0) + assertThat(persisted.infusionDurationMin).isEqualTo(30) + assertThat(persisted.infusionId).isNotEmpty() + } + + @Test + fun `success stamps patch mode 5 and leaves bolus action seq untouched`() { + val original = patchInfo().copy(bolusActionSeq = 4, updatedAt = DateTime.now().minusHours(1)) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(original) + whenever(infusionInfoRepository.updateExtendBolusInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistExtendBolusStarted(volume = 3.0, speed = 6.0, minutes = 30)).isTrue() + + val persisted = capturedPatch() + assertThat(persisted.mode).isEqualTo(5) + // the extend-bolus start does NOT allocate an action seq (unlike the imme-bolus start) + assertThat(persisted.bolusActionSeq).isEqualTo(4) + assertThat(persisted.updatedAt.millis).isGreaterThan(original.updatedAt.millis) + } + + @Test + fun `success writes infusion info before the patch mode flip`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(infusionInfoRepository.updateExtendBolusInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistExtendBolusStarted(volume = 1.0, speed = 2.0, minutes = 30)).isTrue() + + val order = inOrder(infusionInfoRepository, patchInfoRepository) + order.verify(infusionInfoRepository).updateExtendBolusInfusionInfo(any()) + order.verify(patchInfoRepository).updatePatchInfo(any()) + } + + @Test + fun `each started extend bolus gets a distinct infusion id`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(infusionInfoRepository.updateExtendBolusInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistExtendBolusStarted(volume = 1.0, speed = 2.0, minutes = 30)).isTrue() + assertThat(sut.persistExtendBolusStarted(volume = 1.0, speed = 2.0, minutes = 30)).isTrue() + + val captor = argumentCaptor() + verify(infusionInfoRepository, times(2)).updateExtendBolusInfusionInfo(captor.capture()) + assertThat(captor.firstValue.infusionId).isNotEqualTo(captor.secondValue.infusionId) + } + + @Test + fun `success stores the caller supplied speed verbatim without recomputing it from volume and minutes`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(infusionInfoRepository.updateExtendBolusInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + // 2.5 U over 90 min would be 1.666.. U/h; the use case must persist exactly what it was handed + assertThat(sut.persistExtendBolusStarted(volume = 2.5, speed = 1.5, minutes = 90)).isTrue() + + val persisted = capturedInfusion() + assertThat(persisted.volume).isEqualTo(2.5) + assertThat(persisted.speed).isEqualTo(1.5) + assertThat(persisted.infusionDurationMin).isEqualTo(90) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoStartImmeBolusInfusionUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoStartImmeBolusInfusionUseCaseTest.kt new file mode 100644 index 000000000000..c1f2f2d25870 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/CarelevoStartImmeBolusInfusionUseCaseTest.kt @@ -0,0 +1,163 @@ +package app.aaps.pump.carelevo.domain.usecase.bolus + +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoImmeBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import com.google.common.truth.Truth.assertThat +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoStartImmeBolusInfusionUseCaseTest { + + private val patchInfoRepository: CarelevoPatchInfoRepository = mock() + private val infusionInfoRepository: CarelevoInfusionInfoRepository = mock() + private val sut = CarelevoStartImmeBolusInfusionUseCase(patchInfoRepository, infusionInfoRepository) + + private fun patchInfo(address: String = "AA:BB:CC:DD:EE:FF"): CarelevoPatchInfoDomainModel = + CarelevoPatchInfoDomainModel(address = address, createdAt = DateTime.now(), updatedAt = DateTime.now(), mode = 1) + + private fun capturedInfusion(): CarelevoImmeBolusInfusionInfoDomainModel { + val captor = argumentCaptor() + verify(infusionInfoRepository).updateImmeBolusInfusionInfo(captor.capture()) + return captor.firstValue + } + + private fun capturedPatch(): CarelevoPatchInfoDomainModel { + val captor = argumentCaptor() + verify(patchInfoRepository).updatePatchInfo(captor.capture()) + return captor.firstValue + } + + // ---------- guard / failure branches ---------- + + @Test + fun `returns false when no patch record exists and never writes infusion info`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(null) + + assertThat(sut.persistImmeBolusStarted(actionSeq = 1, volume = 2.0, expectedTimeSeconds = 30)).isFalse() + verify(infusionInfoRepository, never()).updateImmeBolusInfusionInfo(any()) + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `returns false when imme bolus infusion update fails and never updates patch`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(infusionInfoRepository.updateImmeBolusInfusionInfo(any())).thenReturn(false) + + assertThat(sut.persistImmeBolusStarted(actionSeq = 1, volume = 2.0, expectedTimeSeconds = 30)).isFalse() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `returns false when patch update fails`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(infusionInfoRepository.updateImmeBolusInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(false) + + assertThat(sut.persistImmeBolusStarted(actionSeq = 1, volume = 2.0, expectedTimeSeconds = 30)).isFalse() + } + + @Test + fun `returns false when patch read throws`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenThrow(RuntimeException("boom")) + + assertThat(sut.persistImmeBolusStarted(actionSeq = 1, volume = 2.0, expectedTimeSeconds = 30)).isFalse() + verify(infusionInfoRepository, never()).updateImmeBolusInfusionInfo(any()) + } + + @Test + fun `returns false when infusion update throws`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(infusionInfoRepository.updateImmeBolusInfusionInfo(any())).thenThrow(RuntimeException("boom")) + + assertThat(sut.persistImmeBolusStarted(actionSeq = 1, volume = 2.0, expectedTimeSeconds = 30)).isFalse() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + // ---------- success ---------- + + @Test + fun `success persists mode 3 imme bolus info carrying volume and expected duration`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo("11:22:33:44:55:66")) + whenever(infusionInfoRepository.updateImmeBolusInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistImmeBolusStarted(actionSeq = 9, volume = 3.5, expectedTimeSeconds = 42)).isTrue() + + val persisted = capturedInfusion() + assertThat(persisted.address).isEqualTo("11:22:33:44:55:66") + assertThat(persisted.mode).isEqualTo(3) + assertThat(persisted.volume).isEqualTo(3.5) + assertThat(persisted.infusionDurationSeconds).isEqualTo(42) + assertThat(persisted.infusionId).isNotEmpty() + } + + @Test + fun `success stamps patch mode 3 and the bolus action seq`() { + val original = patchInfo().copy(updatedAt = DateTime.now().minusHours(1)) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(original) + whenever(infusionInfoRepository.updateImmeBolusInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistImmeBolusStarted(actionSeq = 9, volume = 3.5, expectedTimeSeconds = 42)).isTrue() + + val persisted = capturedPatch() + assertThat(persisted.mode).isEqualTo(3) + assertThat(persisted.bolusActionSeq).isEqualTo(9) + assertThat(persisted.updatedAt.millis).isGreaterThan(original.updatedAt.millis) + } + + @Test + fun `success writes infusion info before the patch mode flip`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(infusionInfoRepository.updateImmeBolusInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistImmeBolusStarted(actionSeq = 0, volume = 1.0, expectedTimeSeconds = 10)).isTrue() + + val order = inOrder(infusionInfoRepository, patchInfoRepository) + order.verify(infusionInfoRepository).updateImmeBolusInfusionInfo(any()) + order.verify(patchInfoRepository).updatePatchInfo(any()) + } + + @Test + fun `each started bolus gets a distinct infusion id`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(infusionInfoRepository.updateImmeBolusInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistImmeBolusStarted(actionSeq = 1, volume = 1.0, expectedTimeSeconds = 10)).isTrue() + assertThat(sut.persistImmeBolusStarted(actionSeq = 2, volume = 1.0, expectedTimeSeconds = 10)).isTrue() + + val captor = argumentCaptor() + verify(infusionInfoRepository, times(2)).updateImmeBolusInfusionInfo(captor.capture()) + assertThat(captor.firstValue.infusionId).isNotEqualTo(captor.secondValue.infusionId) + } + + @Test + fun `success accepts a zero expected time and a zero action seq`() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(infusionInfoRepository.updateImmeBolusInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistImmeBolusStarted(actionSeq = 0, volume = 0.05, expectedTimeSeconds = 0)).isTrue() + + assertThat(capturedInfusion().infusionDurationSeconds).isEqualTo(0) + assertThat(capturedPatch().bolusActionSeq).isEqualTo(0) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/model/CarelevoBolusUseCaseModelTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/model/CarelevoBolusUseCaseModelTest.kt new file mode 100644 index 000000000000..dc99484e4c01 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/bolus/model/CarelevoBolusUseCaseModelTest.kt @@ -0,0 +1,156 @@ +package app.aaps.pump.carelevo.domain.usecase.bolus.model + +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseRequest +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * Value-semantics coverage for the bolus use-case request/response DTOs. These are passed across the + * coordinator boundary and compared/copied there, so the generated data-class contract (equals / + * hashCode / copy / destructuring) is what callers actually rely on. + */ +internal class CarelevoBolusUseCaseModelTest { + + // ---------- StartImmeBolusInfusionRequestModel ---------- + + @Test + fun `StartImmeBolusInfusionRequestModel exposes its action seq and volume`() { + val model = StartImmeBolusInfusionRequestModel(actionSeq = 7, volume = 2.5) + + assertThat(model.actionSeq).isEqualTo(7) + assertThat(model.volume).isEqualTo(2.5) + assertThat(model).isInstanceOf(CarelevoUseCaseRequest::class.java) + } + + @Test + fun `StartImmeBolusInfusionRequestModel has value equality and a matching hashCode`() { + val a = StartImmeBolusInfusionRequestModel(actionSeq = 7, volume = 2.5) + val b = StartImmeBolusInfusionRequestModel(actionSeq = 7, volume = 2.5) + + assertThat(a).isEqualTo(b) + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `StartImmeBolusInfusionRequestModel differs when any field differs`() { + val base = StartImmeBolusInfusionRequestModel(actionSeq = 7, volume = 2.5) + + assertThat(base).isNotEqualTo(base.copy(actionSeq = 8)) + assertThat(base).isNotEqualTo(base.copy(volume = 2.6)) + } + + @Test + fun `StartImmeBolusInfusionRequestModel copy keeps untouched fields and destructures in order`() { + val bumped = StartImmeBolusInfusionRequestModel(actionSeq = 7, volume = 2.5).copy(actionSeq = 8) + + val (actionSeq, volume) = bumped + assertThat(actionSeq).isEqualTo(8) + assertThat(volume).isEqualTo(2.5) + assertThat(bumped.toString()).contains("actionSeq=8") + } + + // ---------- StartExtendBolusInfusionRequestModel ---------- + + @Test + fun `StartExtendBolusInfusionRequestModel exposes its volume and minutes`() { + val model = StartExtendBolusInfusionRequestModel(volume = 3.0, minutes = 90) + + assertThat(model.volume).isEqualTo(3.0) + assertThat(model.minutes).isEqualTo(90) + assertThat(model).isInstanceOf(CarelevoUseCaseRequest::class.java) + } + + @Test + fun `StartExtendBolusInfusionRequestModel has value equality and a matching hashCode`() { + val a = StartExtendBolusInfusionRequestModel(volume = 3.0, minutes = 90) + val b = StartExtendBolusInfusionRequestModel(volume = 3.0, minutes = 90) + + assertThat(a).isEqualTo(b) + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `StartExtendBolusInfusionRequestModel differs when any field differs`() { + val base = StartExtendBolusInfusionRequestModel(volume = 3.0, minutes = 90) + + assertThat(base).isNotEqualTo(base.copy(volume = 3.5)) + assertThat(base).isNotEqualTo(base.copy(minutes = 120)) + } + + @Test + fun `StartExtendBolusInfusionRequestModel copy keeps untouched fields and destructures in order`() { + val extended = StartExtendBolusInfusionRequestModel(volume = 3.0, minutes = 90).copy(minutes = 120) + + val (volume, minutes) = extended + assertThat(volume).isEqualTo(3.0) + assertThat(minutes).isEqualTo(120) + assertThat(extended.toString()).contains("minutes=120") + } + + @Test + fun `the two request models are never equal to each other despite both carrying a volume`() { + val imme = StartImmeBolusInfusionRequestModel(actionSeq = 90, volume = 3.0) + val extend = StartExtendBolusInfusionRequestModel(volume = 3.0, minutes = 90) + + assertThat(imme).isNotEqualTo(extend) + } + + // ---------- CancelBolusInfusionResponseModel ---------- + + @Test + fun `CancelBolusInfusionResponseModel exposes the pump reported infused amount`() { + val model = CancelBolusInfusionResponseModel(infusedAmount = 1.35) + + assertThat(model.infusedAmount).isEqualTo(1.35) + assertThat(model).isInstanceOf(CarelevoUseCaseResponse::class.java) + } + + @Test + fun `CancelBolusInfusionResponseModel has value equality and a matching hashCode`() { + val a = CancelBolusInfusionResponseModel(infusedAmount = 1.35) + val b = CancelBolusInfusionResponseModel(infusedAmount = 1.35) + + assertThat(a).isEqualTo(b) + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + assertThat(a).isNotEqualTo(b.copy(infusedAmount = 1.4)) + } + + @Test + fun `CancelBolusInfusionResponseModel carries a zero infused amount for a bolus cancelled before delivery`() { + val model = CancelBolusInfusionResponseModel(infusedAmount = 0.0) + + val (infusedAmount) = model + assertThat(infusedAmount).isEqualTo(0.0) + assertThat(model.toString()).contains("infusedAmount=0.0") + } + + // ---------- StartImmeBolusInfusionResponseModel ---------- + + @Test + fun `StartImmeBolusInfusionResponseModel exposes the expected completion seconds`() { + val model = StartImmeBolusInfusionResponseModel(expectSec = 42) + + assertThat(model.expectSec).isEqualTo(42) + assertThat(model).isInstanceOf(CarelevoUseCaseResponse::class.java) + } + + @Test + fun `StartImmeBolusInfusionResponseModel has value equality and a matching hashCode`() { + val a = StartImmeBolusInfusionResponseModel(expectSec = 42) + val b = StartImmeBolusInfusionResponseModel(expectSec = 42) + + assertThat(a).isEqualTo(b) + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + assertThat(a).isNotEqualTo(b.copy(expectSec = 43)) + } + + @Test + fun `StartImmeBolusInfusionResponseModel copy and destructuring round trip the expected seconds`() { + val model = StartImmeBolusInfusionResponseModel(expectSec = 42).copy(expectSec = 0) + + val (expectSec) = model + assertThat(expectSec).isEqualTo(0) + assertThat(model.toString()).contains("expectSec=0") + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoDeleteInfusionInfoUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoDeleteInfusionInfoUseCaseTest.kt new file mode 100644 index 000000000000..ba93d2e6f3ac --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoDeleteInfusionInfoUseCaseTest.kt @@ -0,0 +1,39 @@ +package app.aaps.pump.carelevo.domain.usecase.infusion + +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import app.aaps.pump.carelevo.domain.usecase.infusion.model.CarelevoDeleteInfusionRequestModel +import com.google.common.truth.Truth.assertThat +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +internal class CarelevoDeleteInfusionInfoUseCaseTest { + + private val patchInfoRepository: CarelevoPatchInfoRepository = mock() + private val infusionInfoRepository: CarelevoInfusionInfoRepository = mock() + private val sut = CarelevoDeleteInfusionInfoUseCase(patchInfoRepository, infusionInfoRepository) + + @Test + fun execute_returns_success_when_delete_and_patch_update_succeed() { + whenever(infusionInfoRepository.deleteTempBasalInfusionInfo()).thenReturn(true) + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(CarelevoInfusionInfoDomainModel()) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(CarelevoPatchInfoDomainModel("AA:BB", DateTime.now(), DateTime.now(), mode = 1)) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + val result = sut.execute( + CarelevoDeleteInfusionRequestModel( + isDeleteTempBasal = true, + isDeleteImmeBolus = false, + isDeleteExtendBolus = false + ) + ).blockingGet() + + assertThat(result).isInstanceOf(ResponseResult.Success::class.java) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoInfusionInfoMonitorUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoInfusionInfoMonitorUseCaseTest.kt new file mode 100644 index 000000000000..14c4887dd268 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoInfusionInfoMonitorUseCaseTest.kt @@ -0,0 +1,28 @@ +package app.aaps.pump.carelevo.domain.usecase.infusion + +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Observable +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.Optional + +internal class CarelevoInfusionInfoMonitorUseCaseTest { + + private val infusionInfoRepository: CarelevoInfusionInfoRepository = mock() + private val sut = CarelevoInfusionInfoMonitorUseCase(infusionInfoRepository) + + @Test + fun execute_maps_repository_values_to_success() { + whenever(infusionInfoRepository.getInfusionInfo()).thenReturn( + Observable.just(Optional.of(CarelevoInfusionInfoDomainModel())) + ) + + val result = sut.execute().blockingFirst() + + assertThat(result).isInstanceOf(ResponseResult.Success::class.java) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoPumpResumeUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoPumpResumeUseCaseTest.kt new file mode 100644 index 000000000000..6f17b4e29d33 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoPumpResumeUseCaseTest.kt @@ -0,0 +1,153 @@ +package app.aaps.pump.carelevo.domain.usecase.infusion + +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import com.google.common.truth.Truth.assertThat +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +internal class CarelevoPumpResumeUseCaseTest { + + private val patchInfoRepository: CarelevoPatchInfoRepository = mock() + private val infusionInfoRepository: CarelevoInfusionInfoRepository = mock() + private val sut = CarelevoPumpResumeUseCase(patchInfoRepository, infusionInfoRepository) + + private fun basalInfo( + mode: Int = 0, + isStop: Boolean = true + ): CarelevoBasalInfusionInfoDomainModel = + CarelevoBasalInfusionInfoDomainModel( + infusionId = "inf-1", + address = "AA:BB:CC:DD:EE:FF", + mode = mode, + segments = emptyList(), + isStop = isStop + ) + + private fun infusionInfo(basal: CarelevoBasalInfusionInfoDomainModel? = basalInfo()): CarelevoInfusionInfoDomainModel = + CarelevoInfusionInfoDomainModel(basalInfusionInfo = basal) + + private fun patchInfo(): CarelevoPatchInfoDomainModel = + CarelevoPatchInfoDomainModel( + address = "AA:BB:CC:DD:EE:FF", + createdAt = DateTime.now().minusHours(1), + updatedAt = DateTime.now(), + isStopped = true, + stopMinutes = 30, + stopMode = 0, + isForceStopped = true, + mode = 0 + ) + + // ---- success ---- + + @Test + fun `persistResumed returns true when all steps succeed`() { + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(infusionInfo()) + whenever(infusionInfoRepository.updateBasalInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistResumed()).isTrue() + } + + // ---- guard branches (each returns false) ---- + + @Test + fun `persistResumed returns false when infusion info is null`() { + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(null) + + assertThat(sut.persistResumed()).isFalse() + verify(infusionInfoRepository, never()).updateBasalInfusionInfo(any()) + verify(patchInfoRepository, never()).getPatchInfoBySync() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `persistResumed returns false when basal infusion info is null`() { + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(infusionInfo(basal = null)) + + assertThat(sut.persistResumed()).isFalse() + verify(infusionInfoRepository, never()).updateBasalInfusionInfo(any()) + verify(patchInfoRepository, never()).getPatchInfoBySync() + } + + @Test + fun `persistResumed returns false when updateBasalInfusionInfo fails`() { + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(infusionInfo()) + whenever(infusionInfoRepository.updateBasalInfusionInfo(any())).thenReturn(false) + + assertThat(sut.persistResumed()).isFalse() + // Short-circuits before touching the patch record. + verify(patchInfoRepository, never()).getPatchInfoBySync() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `persistResumed returns false when patch info is null`() { + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(infusionInfo()) + whenever(infusionInfoRepository.updateBasalInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(null) + + assertThat(sut.persistResumed()).isFalse() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `persistResumed returns the updatePatchInfo result when the patch write fails`() { + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(infusionInfo()) + whenever(infusionInfoRepository.updateBasalInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(false) + + assertThat(sut.persistResumed()).isFalse() + } + + // ---- persisted-content assertions ---- + + @Test + fun `persistResumed writes the basal copy with mode 1 and isStop false`() { + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(infusionInfo(basal = basalInfo(mode = 0, isStop = true))) + whenever(infusionInfoRepository.updateBasalInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + sut.persistResumed() + + val captor = argumentCaptor() + verify(infusionInfoRepository).updateBasalInfusionInfo(captor.capture()) + assertThat(captor.firstValue.mode).isEqualTo(1) + assertThat(captor.firstValue.isStop).isFalse() + // Identity fields preserved by copy(). + assertThat(captor.firstValue.infusionId).isEqualTo("inf-1") + assertThat(captor.firstValue.address).isEqualTo("AA:BB:CC:DD:EE:FF") + } + + @Test + fun `persistResumed writes the patch copy clearing all stop fields with mode 1`() { + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(infusionInfo()) + whenever(infusionInfoRepository.updateBasalInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + sut.persistResumed() + + val captor = argumentCaptor() + verify(patchInfoRepository).updatePatchInfo(captor.capture()) + val saved = captor.firstValue + assertThat(saved.isStopped).isFalse() + assertThat(saved.stopMinutes).isNull() + assertThat(saved.stopMode).isNull() + assertThat(saved.isForceStopped).isNull() + assertThat(saved.mode).isEqualTo(1) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoPumpStopUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoPumpStopUseCaseTest.kt new file mode 100644 index 000000000000..0f904e4a208f --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/infusion/CarelevoPumpStopUseCaseTest.kt @@ -0,0 +1,167 @@ +package app.aaps.pump.carelevo.domain.usecase.infusion + +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import com.google.common.truth.Truth.assertThat +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +internal class CarelevoPumpStopUseCaseTest { + + private val patchInfoRepository: CarelevoPatchInfoRepository = mock() + private val infusionInfoRepository: CarelevoInfusionInfoRepository = mock() + private val sut = CarelevoPumpStopUseCase(patchInfoRepository, infusionInfoRepository) + + private fun basalInfo( + mode: Int = 1, + isStop: Boolean = false + ): CarelevoBasalInfusionInfoDomainModel = + CarelevoBasalInfusionInfoDomainModel( + infusionId = "inf-1", + address = "AA:BB:CC:DD:EE:FF", + mode = mode, + segments = emptyList(), + isStop = isStop + ) + + private fun infusionInfo(basal: CarelevoBasalInfusionInfoDomainModel? = basalInfo()): CarelevoInfusionInfoDomainModel = + CarelevoInfusionInfoDomainModel(basalInfusionInfo = basal) + + private fun patchInfo(): CarelevoPatchInfoDomainModel = + CarelevoPatchInfoDomainModel( + address = "AA:BB:CC:DD:EE:FF", + createdAt = DateTime.now().minusHours(1), + updatedAt = DateTime.now(), + isStopped = false, + stopMinutes = null, + stopMode = null, + isForceStopped = null, + mode = 1 + ) + + // ---- success ---- + + @Test + fun `persistStopped returns true when all steps succeed`() { + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(infusionInfo()) + whenever(infusionInfoRepository.updateBasalInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + assertThat(sut.persistStopped(30)).isTrue() + } + + // ---- guard branches (each returns false) ---- + + @Test + fun `persistStopped returns false when infusion info is null`() { + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(null) + + assertThat(sut.persistStopped(30)).isFalse() + verify(infusionInfoRepository, never()).updateBasalInfusionInfo(any()) + verify(patchInfoRepository, never()).getPatchInfoBySync() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `persistStopped returns false when basal infusion info is null`() { + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(infusionInfo(basal = null)) + + assertThat(sut.persistStopped(30)).isFalse() + verify(infusionInfoRepository, never()).updateBasalInfusionInfo(any()) + verify(patchInfoRepository, never()).getPatchInfoBySync() + } + + @Test + fun `persistStopped returns false when updateBasalInfusionInfo fails`() { + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(infusionInfo()) + whenever(infusionInfoRepository.updateBasalInfusionInfo(any())).thenReturn(false) + + assertThat(sut.persistStopped(30)).isFalse() + // Short-circuits before touching the patch record. + verify(patchInfoRepository, never()).getPatchInfoBySync() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `persistStopped returns false when patch info is null`() { + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(infusionInfo()) + whenever(infusionInfoRepository.updateBasalInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(null) + + assertThat(sut.persistStopped(30)).isFalse() + verify(patchInfoRepository, never()).updatePatchInfo(any()) + } + + @Test + fun `persistStopped returns the updatePatchInfo result when the patch write fails`() { + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(infusionInfo()) + whenever(infusionInfoRepository.updateBasalInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(false) + + assertThat(sut.persistStopped(30)).isFalse() + } + + // ---- persisted-content assertions ---- + + @Test + fun `persistStopped writes the basal copy with mode 0 and isStop true`() { + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(infusionInfo(basal = basalInfo(mode = 1, isStop = false))) + whenever(infusionInfoRepository.updateBasalInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + sut.persistStopped(30) + + val captor = argumentCaptor() + verify(infusionInfoRepository).updateBasalInfusionInfo(captor.capture()) + assertThat(captor.firstValue.mode).isEqualTo(0) + assertThat(captor.firstValue.isStop).isTrue() + // Identity fields preserved by copy(). + assertThat(captor.firstValue.infusionId).isEqualTo("inf-1") + assertThat(captor.firstValue.address).isEqualTo("AA:BB:CC:DD:EE:FF") + } + + @Test + fun `persistStopped writes the patch copy with stop fields and the given duration`() { + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(infusionInfo()) + whenever(infusionInfoRepository.updateBasalInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + sut.persistStopped(45) + + val captor = argumentCaptor() + verify(patchInfoRepository).updatePatchInfo(captor.capture()) + val saved = captor.firstValue + assertThat(saved.isStopped).isTrue() + assertThat(saved.stopMinutes).isEqualTo(45) + assertThat(saved.stopMode).isEqualTo(0) + assertThat(saved.isForceStopped).isFalse() + assertThat(saved.mode).isEqualTo(0) + } + + @Test + fun `persistStopped propagates a zero duration into the patch stopMinutes`() { + whenever(infusionInfoRepository.getInfusionInfoBySync()).thenReturn(infusionInfo()) + whenever(infusionInfoRepository.updateBasalInfusionInfo(any())).thenReturn(true) + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(patchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + sut.persistStopped(0) + + val captor = argumentCaptor() + verify(patchInfoRepository).updatePatchInfo(captor.capture()) + assertThat(captor.firstValue.stopMinutes).isEqualTo(0) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoConnectNewPatchUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoConnectNewPatchUseCaseTest.kt new file mode 100644 index 000000000000..56cc2f2ceb3a --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoConnectNewPatchUseCaseTest.kt @@ -0,0 +1,77 @@ +package app.aaps.pump.carelevo.domain.usecase.patch + +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import app.aaps.pump.carelevo.domain.usecase.patch.model.CarelevoConnectNewPatchRequestModel +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +internal class CarelevoConnectNewPatchUseCaseTest { + + private val patchInfoRepository: CarelevoPatchInfoRepository = mock() + + private val request = CarelevoConnectNewPatchRequestModel( + volume = 300, + expiry = 120, + remains = 30, + maxBasalSpeed = 15.0, + maxVolume = 25.0, + isBuzzOn = true + ) + + private val sut = CarelevoConnectNewPatchUseCase( + patchInfoRepository = patchInfoRepository + ) + + @Test + fun persistNewPatch_writes_pairing_identity_and_fabricated_boot_time() { + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + + val persisted = sut.persistNewPatch( + address = "94:b2:16:1d:2f:6d", + serialNumber = "EO12507099001", + firmwareVersion = "T168", + modelName = "6776514848", + request = request + ) + + assertThat(persisted).isTrue() + val captor = argumentCaptor() + verify(patchInfoRepository).updatePatchInfo(captor.capture()) + with(captor.firstValue) { + assertThat(address).isEqualTo("94:b2:16:1d:2f:6d") + assertThat(manufactureNumber).isEqualTo("EO12507099001") + assertThat(firmwareVersion).isEqualTo("T168") + assertThat(modelName).isEqualTo("6776514848") + assertThat(insulinAmount).isEqualTo(300) + assertThat(insulinRemain).isEqualTo(300.0) + assertThat(thresholdInsulinRemain).isEqualTo(30) + assertThat(thresholdExpiry).isEqualTo(120) + assertThat(thresholdMaxBasalSpeed).isEqualTo(15.0) + assertThat(thresholdMaxBolusDose).isEqualTo(25.0) + // Fabricated from the phone clock as yyMMddHHmm and parseable back. + assertThat(bootDateTime).hasLength(10) + assertThat(bootDateTimeUtcMillis).isNotNull() + } + } + + @Test + fun persistNewPatch_returns_false_when_repository_write_fails() { + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(false) + + val persisted = sut.persistNewPatch( + address = "94:b2:16:1d:2f:6d", + serialNumber = "EO12507099001", + firmwareVersion = "T168", + modelName = "6776514848", + request = request + ) + + assertThat(persisted).isFalse() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchForceDiscardUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchForceDiscardUseCaseTest.kt new file mode 100644 index 000000000000..1b5078bb6969 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchForceDiscardUseCaseTest.kt @@ -0,0 +1,72 @@ +package app.aaps.pump.carelevo.domain.usecase.patch + +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.userSetting.CarelevoUserSettingInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoInfusionInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import app.aaps.pump.carelevo.domain.repository.CarelevoUserSettingInfoRepository +import com.google.common.truth.Truth.assertThat +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +internal class CarelevoPatchForceDiscardUseCaseTest { + + private val patchInfoRepository: CarelevoPatchInfoRepository = mock() + private val infusionInfoRepository: CarelevoInfusionInfoRepository = mock() + private val userSettingInfoRepository: CarelevoUserSettingInfoRepository = mock() + + private val sut = CarelevoPatchForceDiscardUseCase( + patchInfoRepository = patchInfoRepository, + infusionInfoRepository = infusionInfoRepository, + userSettingInfoRepository = userSettingInfoRepository + ) + + @Test + fun execute_returns_success_when_cleanup_succeeds() { + whenever(userSettingInfoRepository.getUserSettingInfoBySync()).thenReturn(sampleUserSetting()) + whenever(userSettingInfoRepository.updateUserSettingInfo(any())).thenReturn(true) + whenever(infusionInfoRepository.deleteInfusionInfo()).thenReturn(true) + whenever(patchInfoRepository.deletePatchInfo()).thenReturn(true) + + val result = sut.execute().blockingGet() + + assertThat(result).isInstanceOf(ResponseResult.Success::class.java) + } + + @Test + fun execute_returns_error_when_user_setting_missing() { + whenever(userSettingInfoRepository.getUserSettingInfoBySync()).thenReturn(null) + + val result = sut.execute().blockingGet() + + assertThat(result).isInstanceOf(ResponseResult.Error::class.java) + } + + @Test + fun execute_returns_error_when_patch_delete_fails() { + whenever(userSettingInfoRepository.getUserSettingInfoBySync()).thenReturn(sampleUserSetting()) + whenever(userSettingInfoRepository.updateUserSettingInfo(any())).thenReturn(true) + whenever(infusionInfoRepository.deleteInfusionInfo()).thenReturn(true) + whenever(patchInfoRepository.deletePatchInfo()).thenReturn(false) + + val result = sut.execute().blockingGet() + + assertThat(result).isInstanceOf(ResponseResult.Error::class.java) + } + + private fun sampleUserSetting(): CarelevoUserSettingInfoDomainModel { + return CarelevoUserSettingInfoDomainModel( + createdAt = DateTime.now().minusDays(1), + updatedAt = DateTime.now().minusMinutes(10), + lowInsulinNoticeAmount = 30, + maxBasalSpeed = 15.0, + maxBolusDose = 25.0, + needLowInsulinNoticeAmountSyncPatch = true, + needMaxBasalSpeedSyncPatch = true, + needMaxBolusDoseSyncPatch = true + ) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchInfoMonitorUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchInfoMonitorUseCaseTest.kt new file mode 100644 index 000000000000..11df13c8f130 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchInfoMonitorUseCaseTest.kt @@ -0,0 +1,42 @@ +package app.aaps.pump.carelevo.domain.usecase.patch + +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Observable +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.Optional + +internal class CarelevoPatchInfoMonitorUseCaseTest { + + private val patchInfoRepository: CarelevoPatchInfoRepository = mock() + private val sut = CarelevoPatchInfoMonitorUseCase(patchInfoRepository) + + @Test + fun execute_maps_repository_values_to_success() { + val patchInfo = CarelevoPatchInfoDomainModel( + address = "94:b2:16:1d:2f:6d", + createdAt = DateTime.now().minusHours(1), + updatedAt = DateTime.now() + ) + whenever(patchInfoRepository.getPatchInfo()) + .thenReturn(Observable.just(Optional.of(patchInfo))) + + val result = sut.execute().blockingFirst() + + assertThat(result).isInstanceOf(ResponseResult.Success::class.java) + } + + @Test + fun execute_returns_error_when_repository_throws() { + whenever(patchInfoRepository.getPatchInfo()).thenThrow(IllegalStateException("db fail")) + + val result = sut.execute().blockingFirst() + + assertThat(result).isInstanceOf(ResponseResult.Error::class.java) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchRptInfusionInfoProcessUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchRptInfusionInfoProcessUseCaseTest.kt new file mode 100644 index 000000000000..4963c8e8dbff --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/patch/CarelevoPatchRptInfusionInfoProcessUseCaseTest.kt @@ -0,0 +1,77 @@ +package app.aaps.pump.carelevo.domain.usecase.patch + +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoPatchInfoRepository +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseRequest +import app.aaps.pump.carelevo.domain.usecase.patch.model.CarelevoPatchRptInfusionInfoDefaultRequestModel +import app.aaps.pump.carelevo.domain.usecase.patch.model.CarelevoPatchRptInfusionInfoRequestModel +import com.google.common.truth.Truth.assertThat +import org.joda.time.DateTime +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +internal class CarelevoPatchRptInfusionInfoProcessUseCaseTest { + + private val patchInfoRepository: CarelevoPatchInfoRepository = mock() + private val sut = CarelevoPatchRptInfusionInfoProcessUseCase(patchInfoRepository) + + @Test + fun execute_returns_success_for_full_report_request() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(samplePatchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + val request = CarelevoPatchRptInfusionInfoRequestModel( + runningMinute = 120, + remains = 230.5, + infusedTotalBasalAmount = 12.3, + infusedTotalBolusAmount = 4.5, + pumpState = 1, + mode = 3, + currentInfusedProgramVolume = 0.0, + realInfusedTime = 0 + ) + + val result = sut.execute(request).blockingGet() + + assertThat(result).isInstanceOf(ResponseResult.Success::class.java) + } + + @Test + fun execute_returns_success_for_default_report_request() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(samplePatchInfo()) + whenever(patchInfoRepository.updatePatchInfo(any())).thenReturn(true) + val request = CarelevoPatchRptInfusionInfoDefaultRequestModel(remains = 199.0) + + val result = sut.execute(request).blockingGet() + + assertThat(result).isInstanceOf(ResponseResult.Success::class.java) + } + + @Test + fun execute_returns_error_for_invalid_request_type() { + val invalidRequest = object : CarelevoUseCaseRequest {} + + val result = sut.execute(invalidRequest).blockingGet() + + assertThat(result).isInstanceOf(ResponseResult.Error::class.java) + } + + @Test + fun execute_returns_error_when_patch_info_missing() { + whenever(patchInfoRepository.getPatchInfoBySync()).thenReturn(null) + + val result = sut.execute(CarelevoPatchRptInfusionInfoDefaultRequestModel(remains = 99.0)).blockingGet() + + assertThat(result).isInstanceOf(ResponseResult.Error::class.java) + } + + private fun samplePatchInfo(): CarelevoPatchInfoDomainModel { + return CarelevoPatchInfoDomainModel( + address = "94:b2:16:1d:2f:6d", + createdAt = DateTime.now().minusHours(1), + updatedAt = DateTime.now().minusMinutes(1) + ) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoCreateUserSettingInfoUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoCreateUserSettingInfoUseCaseTest.kt new file mode 100644 index 000000000000..8d8a574e895c --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoCreateUserSettingInfoUseCaseTest.kt @@ -0,0 +1,31 @@ +package app.aaps.pump.carelevo.domain.usecase.userSetting + +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.repository.CarelevoUserSettingInfoRepository +import app.aaps.pump.carelevo.domain.usecase.userSetting.model.CarelevoUserSettingInfoRequestModel +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +internal class CarelevoCreateUserSettingInfoUseCaseTest { + + private val userSettingInfoRepository: CarelevoUserSettingInfoRepository = mock() + private val sut = CarelevoCreateUserSettingInfoUseCase(userSettingInfoRepository) + + @Test + fun execute_returns_success_when_create_succeeds() { + whenever(userSettingInfoRepository.updateUserSettingInfo(any())).thenReturn(true) + + val result = sut.execute( + CarelevoUserSettingInfoRequestModel( + lowInsulinNoticeAmount = 30, + maxBasalSpeed = 15.0, + maxBolusDose = 10.0 + ) + ).blockingGet() + + assertThat(result).isInstanceOf(ResponseResult.Success::class.java) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoDeleteUserSettingInfoUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoDeleteUserSettingInfoUseCaseTest.kt new file mode 100644 index 000000000000..8d5980b99889 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoDeleteUserSettingInfoUseCaseTest.kt @@ -0,0 +1,23 @@ +package app.aaps.pump.carelevo.domain.usecase.userSetting + +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.repository.CarelevoUserSettingInfoRepository +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +internal class CarelevoDeleteUserSettingInfoUseCaseTest { + + private val userSettingInfoRepository: CarelevoUserSettingInfoRepository = mock() + private val sut = CarelevoDeleteUserSettingInfoUseCase(userSettingInfoRepository) + + @Test + fun execute_returns_success_when_delete_succeeds() { + whenever(userSettingInfoRepository.deleteUserSettingInfo()).thenReturn(true) + + val result = sut.execute().blockingGet() + + assertThat(result).isInstanceOf(ResponseResult.Success::class.java) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoUserSettingInfoMonitorUseCaseTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoUserSettingInfoMonitorUseCaseTest.kt new file mode 100644 index 000000000000..59c1e35b28cd --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/domain/usecase/userSetting/CarelevoUserSettingInfoMonitorUseCaseTest.kt @@ -0,0 +1,28 @@ +package app.aaps.pump.carelevo.domain.usecase.userSetting + +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.userSetting.CarelevoUserSettingInfoDomainModel +import app.aaps.pump.carelevo.domain.repository.CarelevoUserSettingInfoRepository +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Observable +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.Optional + +internal class CarelevoUserSettingInfoMonitorUseCaseTest { + + private val userSettingInfoRepository: CarelevoUserSettingInfoRepository = mock() + private val sut = CarelevoUserSettingInfoMonitorUseCase(userSettingInfoRepository) + + @Test + fun execute_maps_repository_values_to_success() { + whenever(userSettingInfoRepository.getUserSettingInfo()).thenReturn( + Observable.just(Optional.of(CarelevoUserSettingInfoDomainModel())) + ) + + val result = sut.execute().blockingFirst() + + assertThat(result).isInstanceOf(ResponseResult.Success::class.java) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ext/AlarmExtTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ext/AlarmExtTest.kt new file mode 100644 index 000000000000..dde294af8318 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ext/AlarmExtTest.kt @@ -0,0 +1,431 @@ +package app.aaps.pump.carelevo.ext + +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.domain.type.AlarmCause +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * Covers the full [AlarmCause] -> string-resource table in `AlarmExt.kt`. + * + * Expected resource IDs are asserted against the same `R.string.*` constants the production code + * references (both resolve to the same runtime int), so the test verifies the *mapping* without + * hardcoding opaque integers and without needing a Context. + */ +internal class AlarmExtTest { + + private fun expect( + cause: AlarmCause, + title: Int, + screen: Int?, + notification: Int?, + btn: Int + ) { + val actual = cause.stringResources() + assertThat(actual).isEqualTo(AlarmStringResources(title, screen, notification, btn)) + // transform helpers must be pure projections of the same table row + assertThat(cause.transformStringResources()).isEqualTo(Triple(title, screen, btn)) + assertThat(cause.transformNotificationStringResources()).isEqualTo(Triple(title, notification, btn)) + } + + // --------------------------------------------------------------------------------------------- + // WARNING tier + // --------------------------------------------------------------------------------------------- + + @Test fun `warning low insulin`() = expect( + AlarmCause.ALARM_WARNING_LOW_INSULIN, + R.string.alarm_feat_title_warning_low_insulin, + R.string.alarm_feat_desc_warning_low_insulin, + R.string.alarm_notification_desc_warning_low_insulin, + R.string.alarm_feat_btn_patch_discard + ) + + @Test fun `warning patch expired phase 1 shares the expired-patch row`() = expect( + AlarmCause.ALARM_WARNING_PATCH_EXPIRED_PHASE_1, + R.string.alarm_feat_title_warning_expired_patch, + R.string.alarm_feat_desc_warning_expired_patch, + R.string.alarm_notification_desc_warning_expired_patch, + R.string.alarm_feat_btn_patch_discard + ) + + @Test fun `warning patch expired shares the expired-patch row`() = expect( + AlarmCause.ALARM_WARNING_PATCH_EXPIRED, + R.string.alarm_feat_title_warning_expired_patch, + R.string.alarm_feat_desc_warning_expired_patch, + R.string.alarm_notification_desc_warning_expired_patch, + R.string.alarm_feat_btn_patch_discard + ) + + @Test fun `warning phase1 and warning expired map to identical resources`() { + assertThat(AlarmCause.ALARM_WARNING_PATCH_EXPIRED_PHASE_1.stringResources()) + .isEqualTo(AlarmCause.ALARM_WARNING_PATCH_EXPIRED.stringResources()) + } + + @Test fun `warning low battery uses force-discard button`() = expect( + AlarmCause.ALARM_WARNING_LOW_BATTERY, + R.string.alarm_feat_title_warning_low_battery, + R.string.alarm_feat_desc_warning_low_battery, + R.string.alarm_notification_desc_warning_low_battery, + R.string.alarm_feat_btn_patch_force_discard + ) + + @Test fun `warning invalid temperature`() = expect( + AlarmCause.ALARM_WARNING_INVALID_TEMPERATURE, + R.string.alarm_feat_title_warning_invalid_temperature, + R.string.alarm_feat_desc_warning_invalid_temperature, + R.string.alarm_notification_desc_warning_invalid_temperature, + R.string.alarm_feat_btn_patch_discard + ) + + @Test fun `warning not used app uses resume-infusion button`() = expect( + AlarmCause.ALARM_WARNING_NOT_USED_APP_AUTO_OFF, + R.string.alarm_feat_title_warning_not_used_app, + R.string.alarm_feat_desc_warning_not_used_app, + R.string.alarm_notification_desc_warning_not_used_app, + R.string.alarm_feat_btn_resume_infusion + ) + + @Test fun `warning ble not connected has null descriptions`() = expect( + AlarmCause.ALARM_WARNING_BLE_NOT_CONNECTED, + R.string.alarm_feat_title_warning_not_connected_ble, + null, + null, + R.string.alarm_feat_btn_patch_force_discard + ) + + @Test fun `warning incomplete patch setting`() = expect( + AlarmCause.ALARM_WARNING_INCOMPLETE_PATCH_SETTING, + R.string.alarm_feat_title_warning_incomplete_patch_setting, + R.string.alarm_feat_desc_warning_incomplete_patch_setting, + R.string.alarm_notification_desc_warning_incomplete_patch_setting, + R.string.alarm_feat_btn_patch_discard + ) + + @Test fun `warning self diagnosis failed`() = expect( + AlarmCause.ALARM_WARNING_SELF_DIAGNOSIS_FAILED, + R.string.alarm_feat_title_warning_failed_safety_check, + R.string.alarm_feat_desc_warning_failed_safety_check, + R.string.alarm_notification_desc_warning_failed_safety_check, + R.string.alarm_feat_btn_patch_discard + ) + + @Test fun `warning patch error`() = expect( + AlarmCause.ALARM_WARNING_PATCH_ERROR, + R.string.alarm_feat_title_warning_patch_error, + R.string.alarm_feat_desc_warning_patch_error, + R.string.alarm_notification_desc_warning_patch_error, + R.string.alarm_feat_btn_patch_discard + ) + + @Test fun `warning pump clogged`() = expect( + AlarmCause.ALARM_WARNING_PUMP_CLOGGED, + R.string.alarm_feat_title_warning_infusion_clogged, + R.string.alarm_feat_desc_warning_infusion_clogged, + R.string.alarm_notification_desc_warning_infusion_clogged, + R.string.alarm_feat_btn_patch_discard + ) + + @Test fun `warning needle insertion error`() = expect( + AlarmCause.ALARM_WARNING_NEEDLE_INSERTION_ERROR, + R.string.alarm_feat_title_warning_needle_injection_error, + R.string.alarm_feat_desc_warning_needle_injection_error, + R.string.alarm_notification_desc_warning_needle_injection_error, + R.string.alarm_feat_btn_patch_discard + ) + + // --------------------------------------------------------------------------------------------- + // ALERT tier + // --------------------------------------------------------------------------------------------- + + @Test fun `alert out of insulin`() = expect( + AlarmCause.ALARM_ALERT_OUT_OF_INSULIN, + R.string.alarm_feat_title_alert_low_insulin, + R.string.alarm_feat_desc_alert_low_insulin, + R.string.alarm_notification_desc_alert_low_insulin, + R.string.common_btn_ok + ) + + @Test fun `alert patch expired phase 1`() = expect( + AlarmCause.ALARM_ALERT_PATCH_EXPIRED_PHASE_1, + R.string.alarm_feat_title_alert_expired_patch_phase1, + R.string.alarm_feat_desc_alert_expired_patch_phase1, + R.string.alarm_notification_desc_alert_expired_patch_phase1, + R.string.common_btn_ok + ) + + @Test fun `alert patch expired phase 2`() = expect( + AlarmCause.ALARM_ALERT_PATCH_EXPIRED_PHASE_2, + R.string.alarm_feat_title_alert_expired_patch_phase2, + R.string.alarm_feat_desc_alert_expired_patch_phase2, + R.string.alarm_notification_desc_alert_expired_patch_phase2, + R.string.common_btn_ok + ) + + @Test fun `alert phase1 and phase2 map to distinct resources`() { + assertThat(AlarmCause.ALARM_ALERT_PATCH_EXPIRED_PHASE_1.stringResources()) + .isNotEqualTo(AlarmCause.ALARM_ALERT_PATCH_EXPIRED_PHASE_2.stringResources()) + } + + @Test fun `alert low battery`() = expect( + AlarmCause.ALARM_ALERT_LOW_BATTERY, + R.string.alarm_feat_title_alert_low_battery, + R.string.alarm_feat_desc_alert_low_battery, + R.string.alarm_notification_desc_alert_low_battery, + R.string.common_btn_ok + ) + + @Test fun `alert invalid temperature`() = expect( + AlarmCause.ALARM_ALERT_INVALID_TEMPERATURE, + R.string.alarm_feat_title_alert_invalid_temperature, + R.string.alarm_feat_desc_alert_invalid_temperature, + R.string.alarm_notification_desc_alert_invalid_temperature, + R.string.common_btn_ok + ) + + @Test fun `alert app no use`() = expect( + AlarmCause.ALARM_ALERT_APP_NO_USE, + R.string.alarm_feat_title_alert_not_used_app, + R.string.alarm_feat_desc_alert_not_used_app, + R.string.alarm_notification_desc_alert_not_used_app, + R.string.common_btn_ok + ) + + @Test fun `alert ble not connected has null descriptions`() = expect( + AlarmCause.ALARM_ALERT_BLE_NOT_CONNECTED, + R.string.alarm_feat_title_alert_not_connected_ble, + null, + null, + R.string.common_btn_ok + ) + + @Test fun `alert patch application incomplete`() = expect( + AlarmCause.ALARM_ALERT_PATCH_APPLICATION_INCOMPLETE, + R.string.alarm_feat_title_alert_incomplete_patch_setting, + R.string.alarm_feat_desc_alert_incomplete_patch_setting, + R.string.alarm_notification_desc_alert_incomplete_patch_setting, + R.string.common_btn_ok + ) + + @Test fun `alert resume insulin delivery timeout uses resume-infusion button`() = expect( + AlarmCause.ALARM_ALERT_RESUME_INSULIN_DELIVERY_TIMEOUT, + R.string.alarm_feat_title_alert_resume_infusion, + R.string.alarm_feat_desc_alert_resume_infusion, + R.string.alarm_notification_desc_alert_resume_infusion, + R.string.alarm_feat_btn_resume_infusion + ) + + @Test fun `alert bluetooth off`() = expect( + AlarmCause.ALARM_ALERT_BLUETOOTH_OFF, + R.string.alarm_feat_title_alert_off_bluetooth, + R.string.alarm_feat_desc_alert_off_bluetooth, + R.string.alarm_notification_desc_alert_off_bluetooth, + R.string.common_btn_ok + ) + + // --------------------------------------------------------------------------------------------- + // NOTICE tier + // --------------------------------------------------------------------------------------------- + + @Test fun `notice low insulin`() = expect( + AlarmCause.ALARM_NOTICE_LOW_INSULIN, + R.string.alarm_feat_title_notice_low_insulin, + R.string.alarm_feat_desc_notice_low_insulin, + R.string.alarm_notification_desc_notice_low_insulin, + R.string.common_btn_ok + ) + + @Test fun `notice patch expired`() = expect( + AlarmCause.ALARM_NOTICE_PATCH_EXPIRED, + R.string.alarm_feat_title_notice_expired_patch, + R.string.alarm_feat_desc_notice_expired_patch, + R.string.alarm_notification_desc_notice_expired_patch, + R.string.common_btn_ok + ) + + @Test fun `notice attach patch check has null descriptions`() = expect( + AlarmCause.ALARM_NOTICE_ATTACH_PATCH_CHECK, + R.string.alarm_feat_title_notice_check_patch, + null, + null, + R.string.common_btn_ok + ) + + @Test fun `notice bg check`() = expect( + AlarmCause.ALARM_NOTICE_BG_CHECK, + R.string.alarm_feat_title_notice_check_bg, + R.string.alarm_feat_desc_notice_check_bg, + R.string.alarm_notification_desc_notice_check_bg, + R.string.common_btn_ok + ) + + @Test fun `notice time zone changed`() = expect( + AlarmCause.ALARM_NOTICE_TIME_ZONE_CHANGED, + R.string.alarm_feat_title_notice_change_time_zone, + R.string.alarm_feat_desc_notice_change_time_zone, + R.string.alarm_notification_desc_notice_change_time_zone, + R.string.common_btn_ok + ) + + @Test fun `notice lgs start`() = expect( + AlarmCause.ALARM_NOTICE_LGS_START, + R.string.alarm_feat_title_notice_lgs_started, + R.string.alarm_feat_desc_notice_lgs_started, + R.string.alarm_notification_desc_notice_lgs_started, + R.string.common_btn_ok + ) + + @Test fun `notice lgs finished disconnected patch or cgm`() = expect( + AlarmCause.ALARM_NOTICE_LGS_FINISHED_DISCONNECTED_PATCH_OR_CGM, + R.string.alarm_feat_title_notice_lgs_ended, + R.string.alarm_feat_desc_notice_lgs_ended_disconnected_patch_or_cgm, + R.string.alarm_notification_desc_notice_lgs_ended_disconnected_patch_or_cgm, + R.string.common_btn_ok + ) + + @Test fun `notice lgs finished pause lgs`() = expect( + AlarmCause.ALARM_NOTICE_LGS_FINISHED_PAUSE_LGS, + R.string.alarm_feat_title_notice_lgs_ended, + R.string.alarm_feat_desc_notice_lgs_ended_pause_lgs, + R.string.alarm_notification_desc_notice_lgs_ended_pause_lgs, + R.string.common_btn_ok + ) + + @Test fun `notice lgs finished time over`() = expect( + AlarmCause.ALARM_NOTICE_LGS_FINISHED_TIME_OVER, + R.string.alarm_feat_title_notice_lgs_ended, + R.string.alarm_feat_desc_notice_lgs_ended_time_over, + R.string.alarm_notification_desc_notice_lgs_ended_time_over, + R.string.common_btn_ok + ) + + @Test fun `notice lgs finished off lgs`() = expect( + AlarmCause.ALARM_NOTICE_LGS_FINISHED_OFF_LGS, + R.string.alarm_feat_title_notice_lgs_ended, + R.string.alarm_feat_desc_notice_lgs_ended_off_lgs, + R.string.alarm_notification_desc_notice_lgs_ended_off_lgs, + R.string.common_btn_ok + ) + + @Test fun `notice lgs finished high bg`() = expect( + AlarmCause.ALARM_NOTICE_LGS_FINISHED_HIGH_BG, + R.string.alarm_feat_title_notice_lgs_ended, + R.string.alarm_feat_desc_notice_lgs_ended_high_bg, + R.string.alarm_notification_desc_notice_lgs_ended_high_bg, + R.string.common_btn_ok + ) + + @Test fun `notice lgs finished unknown`() = expect( + AlarmCause.ALARM_NOTICE_LGS_FINISHED_UNKNOWN, + R.string.alarm_feat_title_notice_lgs_ended, + R.string.alarm_feat_desc_notice_lgs_ended_unknown, + R.string.alarm_notification_desc_notice_lgs_ended_unknown, + R.string.common_btn_ok + ) + + @Test fun `all lgs-finished causes share the ended title but differ in description`() { + val lgsEnded = listOf( + AlarmCause.ALARM_NOTICE_LGS_FINISHED_DISCONNECTED_PATCH_OR_CGM, + AlarmCause.ALARM_NOTICE_LGS_FINISHED_PAUSE_LGS, + AlarmCause.ALARM_NOTICE_LGS_FINISHED_TIME_OVER, + AlarmCause.ALARM_NOTICE_LGS_FINISHED_OFF_LGS, + AlarmCause.ALARM_NOTICE_LGS_FINISHED_HIGH_BG, + AlarmCause.ALARM_NOTICE_LGS_FINISHED_UNKNOWN + ) + // one shared title + assertThat(lgsEnded.map { it.stringResources().titleRes }.toSet()) + .containsExactly(R.string.alarm_feat_title_notice_lgs_ended) + // but six distinct screen descriptions + assertThat(lgsEnded.map { it.stringResources().screenDescRes }.toSet()).hasSize(6) + } + + @Test fun `notice lgs not working`() = expect( + AlarmCause.ALARM_NOTICE_LGS_NOT_WORKING, + R.string.alarm_feat_title_notice_lgs_error, + R.string.alarm_feat_desc_notice_lgs_error, + R.string.alarm_notification_desc_notice_lgs_error, + R.string.common_btn_ok + ) + + // --------------------------------------------------------------------------------------------- + // UNKNOWN + // --------------------------------------------------------------------------------------------- + + @Test fun `unknown reuses the same desc for screen and notification`() = expect( + AlarmCause.ALARM_UNKNOWN, + R.string.alarm_feat_title_notice_unknown, + R.string.alarm_feat_desc_unknown, + R.string.alarm_feat_desc_unknown, + R.string.common_btn_ok + ) + + @Test fun `unknown screen and notification descriptions are identical`() { + val sr = AlarmCause.ALARM_UNKNOWN.stringResources() + assertThat(sr.screenDescRes).isEqualTo(sr.notificationDescRes) + } + + // --------------------------------------------------------------------------------------------- + // Table-wide invariants (drives every when-branch a second way) + // --------------------------------------------------------------------------------------------- + + @Test fun `every cause maps to a non-empty resource table`() { + for (cause in AlarmCause.entries) { + val sr = cause.stringResources() + assertThat(sr.titleRes).isNotEqualTo(0) + assertThat(sr.btnRes).isNotEqualTo(0) + } + } + + @Test fun `transformStringResources projects title-screen-button for every cause`() { + for (cause in AlarmCause.entries) { + val sr = cause.stringResources() + assertThat(cause.transformStringResources()) + .isEqualTo(Triple(sr.titleRes, sr.screenDescRes, sr.btnRes)) + } + } + + @Test fun `transformNotificationStringResources projects title-notification-button for every cause`() { + for (cause in AlarmCause.entries) { + val sr = cause.stringResources() + assertThat(cause.transformNotificationStringResources()) + .isEqualTo(Triple(sr.titleRes, sr.notificationDescRes, sr.btnRes)) + } + } + + @Test fun `only ble-not-connected and attach-patch-check causes have null descriptions`() { + val nullDescCauses = AlarmCause.entries.filter { + it.stringResources().screenDescRes == null + }.toSet() + assertThat(nullDescCauses).containsExactly( + AlarmCause.ALARM_WARNING_BLE_NOT_CONNECTED, + AlarmCause.ALARM_ALERT_BLE_NOT_CONNECTED, + AlarmCause.ALARM_NOTICE_ATTACH_PATCH_CHECK + ) + } + + @Test fun `a null screen description implies a null notification description`() { + for (cause in AlarmCause.entries) { + val sr = cause.stringResources() + if (sr.screenDescRes == null) assertThat(sr.notificationDescRes).isNull() + } + } + + // --------------------------------------------------------------------------------------------- + // AlarmStringResources data class + // --------------------------------------------------------------------------------------------- + + @Test fun `AlarmStringResources value equality and copy`() { + val a = AlarmStringResources(1, 2, 3, 4) + val b = AlarmStringResources(1, 2, 3, 4) + assertThat(a).isEqualTo(b) + assertThat(a.copy(screenDescRes = null)).isEqualTo(AlarmStringResources(1, null, 3, 4)) + assertThat(a.copy(screenDescRes = null)).isNotEqualTo(b) + } + + @Test fun `AlarmStringResources holds all four fields`() { + val sr = AlarmStringResources(titleRes = 10, screenDescRes = 20, notificationDescRes = 30, btnRes = 40) + assertThat(sr.titleRes).isEqualTo(10) + assertThat(sr.screenDescRes).isEqualTo(20) + assertThat(sr.notificationDescRes).isEqualTo(30) + assertThat(sr.btnRes).isEqualTo(40) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ext/CarelevoValueExtTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ext/CarelevoValueExtTest.kt new file mode 100644 index 000000000000..0bbbd2b5e050 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/ext/CarelevoValueExtTest.kt @@ -0,0 +1,165 @@ +package app.aaps.pump.carelevo.ext + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * Pure-logic tests for the wire byte helpers in `CarelevoValueExt.kt` + * (`convertBytesToHex`, `convertHexToByteArray`, `checkSum`, `checkSumV2`). + */ +internal class CarelevoValueExtTest { + + // ---------- convertBytesToHex ---------- + + @Test + fun `convertBytesToHex formats a single low byte with leading zero`() { + assertThat(byteArrayOf(0x00).convertBytesToHex()).isEqualTo("0x00") + assertThat(byteArrayOf(0x0a).convertBytesToHex()).isEqualTo("0x0a") + } + + @Test + fun `convertBytesToHex formats two-digit hex without extra padding`() { + // 0x1B = 27 -> "1b" + assertThat(byteArrayOf(0x1B).convertBytesToHex()).isEqualTo("0x1b") + } + + @Test + fun `convertBytesToHex concatenates every byte with a 0x prefix each`() { + assertThat(byteArrayOf(0x1B, 0x64).convertBytesToHex()).isEqualTo("0x1b0x64") + } + + @Test + fun `convertBytesToHex renders negative bytes as their unsigned two's complement`() { + assertThat(byteArrayOf(0xFF.toByte()).convertBytesToHex()).isEqualTo("0xff") + assertThat(byteArrayOf(0x80.toByte()).convertBytesToHex()).isEqualTo("0x80") + } + + @Test + fun `convertBytesToHex of an empty array is the empty string`() { + assertThat(byteArrayOf().convertBytesToHex()).isEqualTo("") + } + + // ---------- convertHexToByteArray ---------- + + @Test + fun `convertHexToByteArray parses a plain even-length hex string`() { + assertThat("1B64".convertHexToByteArray().toList()) + .containsExactly(0x1B.toByte(), 0x64.toByte()).inOrder() + } + + @Test + fun `convertHexToByteArray strips every 0x prefix`() { + assertThat("0x1B0x64".convertHexToByteArray().toList()) + .containsExactly(0x1B.toByte(), 0x64.toByte()).inOrder() + } + + @Test + fun `convertHexToByteArray strips whitespace`() { + assertThat("0x1b 0x64".convertHexToByteArray().toList()) + .containsExactly(0x1B.toByte(), 0x64.toByte()).inOrder() + } + + @Test + fun `convertHexToByteArray is case-insensitive`() { + assertThat("1b64".convertHexToByteArray().toList()) + .containsExactly(0x1B.toByte(), 0x64.toByte()).inOrder() + } + + @Test + fun `convertHexToByteArray parses a high byte to its signed value`() { + // BigInteger("FF",16)=255 -> toByte() = -1 + assertThat("ff".convertHexToByteArray().toList()) + .containsExactly((-1).toByte()).inOrder() + } + + @Test + fun `convertHexToByteArray of empty string is empty`() { + assertThat("".convertHexToByteArray()).isEmpty() + } + + @Test + fun `convertHexToByteArray of an odd-length string yields empty`() { + // (length % 2 == 0) gate is false for 3 chars -> nothing decoded. + assertThat("ABC".convertHexToByteArray()).isEmpty() + } + + @Test + fun `convertHexToByteArray of invalid hex swallows the exception and yields empty`() { + // BigInteger("ZZ",16) throws; it is caught and the accumulator stays empty. + assertThat("ZZ".convertHexToByteArray()).isEmpty() + } + + @Test + fun `convertHexToByteArray decodes bytes up to the first invalid pair`() { + // "1B" decodes, then "ZZ" throws and aborts the loop, keeping what was collected. + assertThat("1BZZ".convertHexToByteArray().toList()) + .containsExactly(0x1B.toByte()).inOrder() + } + + @Test + fun `bytes-to-hex-to-bytes round trips`() { + val original = byteArrayOf(0x1B, 0x64, 0x00, 0xFF.toByte()) + val roundTripped = original.convertBytesToHex().convertHexToByteArray() + assertThat(roundTripped.toList()).containsExactly(*original.toTypedArray()).inOrder() + } + + // ---------- checkSum ---------- + + @Test + fun `checkSum returns true when the running xor matches the expected result`() { + // 0 ^ 1 ^ 2 ^ 4 = 7 + assertThat(byteArrayOf(0x01, 0x02, 0x04).checkSum(key = 0, result = 7)).isTrue() + } + + @Test + fun `checkSum returns false on a mismatch`() { + assertThat(byteArrayOf(0x01, 0x02, 0x04).checkSum(key = 0, result = 8)).isFalse() + } + + @Test + fun `checkSum folds in a non-zero key`() { + // 0x10 ^ 0x01 = 0x11 + assertThat(byteArrayOf(0x01).checkSum(key = 0x10, result = 0x11)).isTrue() + } + + @Test + fun `checkSum of an empty array equals the key byte`() { + assertThat(byteArrayOf().checkSum(key = 5, result = 5)).isTrue() + assertThat(byteArrayOf().checkSum(key = 5, result = 6)).isFalse() + } + + @Test + fun `checkSum compares only the low byte of result`() { + // 263 & 0xFF = 7, so the high bits of result are ignored. + assertThat(byteArrayOf(0x01, 0x02, 0x04).checkSum(key = 0, result = 263)).isTrue() + } + + // ---------- checkSumV2 ---------- + + @Test + fun `checkSumV2 returns the xor accumulation`() { + assertThat(byteArrayOf(0x01, 0x02, 0x04).checkSumV2(key = 0)).isEqualTo(7.toByte()) + } + + @Test + fun `checkSumV2 of an empty array equals the key byte`() { + assertThat(byteArrayOf().checkSumV2(key = 5)).isEqualTo(5.toByte()) + } + + @Test + fun `checkSumV2 folds in a non-zero key`() { + assertThat(byteArrayOf(0x01).checkSumV2(key = 0x10)).isEqualTo(0x11.toByte()) + } + + @Test + fun `checkSumV2 of a high byte with zero key is that byte`() { + assertThat(byteArrayOf(0xFF.toByte()).checkSumV2(key = 0)).isEqualTo(0xFF.toByte()) + } + + @Test + fun `checkSumV2 result satisfies checkSum for the same key`() { + val data = byteArrayOf(0x1B, 0x64, 0x74) + val computed = data.checkSumV2(key = 0) + assertThat(data.checkSum(key = 0, result = computed.toInt())).isTrue() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/model/CarelevoOverviewUiModelTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/model/CarelevoOverviewUiModelTest.kt new file mode 100644 index 000000000000..c04397c508aa --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/model/CarelevoOverviewUiModelTest.kt @@ -0,0 +1,103 @@ +package app.aaps.pump.carelevo.presentation.model + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +internal class CarelevoOverviewUiModelTest { + + private fun model( + serialNumber: String = "SN-123", + lotNumber: String = "LOT-9", + bootDateTimeUi: String = "2026-07-16 10:00", + expirationTime: String = "72h", + infusionStatus: Int? = 1, + insulinRemainText: String = "120 U", + totalBasal: Double = 12.5, + totalBolus: Double = 3.0, + isPumpStopped: Boolean = false, + runningRemainMinutes: Int = 4320 + ) = CarelevoOverviewUiModel( + serialNumber = serialNumber, + lotNumber = lotNumber, + bootDateTimeUi = bootDateTimeUi, + expirationTime = expirationTime, + infusionStatus = infusionStatus, + insulinRemainText = insulinRemainText, + totalBasal = totalBasal, + totalBolus = totalBolus, + isPumpStopped = isPumpStopped, + runningRemainMinutes = runningRemainMinutes + ) + + @Test + fun `constructor stores every field verbatim`() { + val m = model() + assertThat(m.serialNumber).isEqualTo("SN-123") + assertThat(m.lotNumber).isEqualTo("LOT-9") + assertThat(m.bootDateTimeUi).isEqualTo("2026-07-16 10:00") + assertThat(m.expirationTime).isEqualTo("72h") + assertThat(m.infusionStatus).isEqualTo(1) + assertThat(m.insulinRemainText).isEqualTo("120 U") + assertThat(m.totalBasal).isEqualTo(12.5) + assertThat(m.totalBolus).isEqualTo(3.0) + assertThat(m.isPumpStopped).isFalse() + assertThat(m.runningRemainMinutes).isEqualTo(4320) + } + + @Test + fun `infusionStatus may be null`() { + assertThat(model(infusionStatus = null).infusionStatus).isNull() + } + + @Test + fun `isPumpStopped true is preserved`() { + assertThat(model(isPumpStopped = true).isPumpStopped).isTrue() + } + + @Test + fun `copy changes only the requested field`() { + val original = model() + val copy = original.copy(insulinRemainText = "10 U") + assertThat(copy.insulinRemainText).isEqualTo("10 U") + assertThat(copy.serialNumber).isEqualTo(original.serialNumber) + assertThat(copy.runningRemainMinutes).isEqualTo(original.runningRemainMinutes) + assertThat(copy).isNotEqualTo(original) + } + + @Test + fun `equals and hashCode agree for identical models`() { + val a = model() + val b = model() + assertThat(a).isEqualTo(b) + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `models differing by one field are not equal`() { + assertThat(model()).isNotEqualTo(model(totalBolus = 99.0)) + assertThat(model()).isNotEqualTo(model(infusionStatus = null)) + assertThat(model()).isNotEqualTo(model(isPumpStopped = true)) + } + + @Test + fun `destructuring returns components in declaration order`() { + val (sn, lot, boot, exp, status, remain, basal, bolus, stopped, mins) = model() + assertThat(sn).isEqualTo("SN-123") + assertThat(lot).isEqualTo("LOT-9") + assertThat(boot).isEqualTo("2026-07-16 10:00") + assertThat(exp).isEqualTo("72h") + assertThat(status).isEqualTo(1) + assertThat(remain).isEqualTo("120 U") + assertThat(basal).isEqualTo(12.5) + assertThat(bolus).isEqualTo(3.0) + assertThat(stopped).isFalse() + assertThat(mins).isEqualTo(4320) + } + + @Test + fun `toString exposes the field values`() { + val text = model().toString() + assertThat(text).contains("SN-123") + assertThat(text).contains("CarelevoOverviewUiModel") + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/model/CarelevoUiEventModelTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/model/CarelevoUiEventModelTest.kt new file mode 100644 index 000000000000..ce1e1e7b635f --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/model/CarelevoUiEventModelTest.kt @@ -0,0 +1,206 @@ +package app.aaps.pump.carelevo.presentation.model + +import app.aaps.pump.carelevo.common.model.Event +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.type.AlarmCause +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +internal class CarelevoUiEventModelTest { + + private fun alarmInfo(cause: AlarmCause = AlarmCause.ALARM_ALERT_OUT_OF_INSULIN) = + CarelevoAlarmInfo( + alarmId = "alarm-1", + alarmType = cause.alarmType, + cause = cause, + value = null, + createdAt = "2026-07-16T10:00:00", + updatedAt = "2026-07-16T10:00:00", + isAcknowledged = false + ) + + // --- Marker interface --------------------------------------------------- + + @Test + fun `every event hierarchy implements the Event marker`() { + assertThat(CarelevoOverviewEvent.NoAction).isInstanceOf(Event::class.java) + assertThat(CarelevoConnectEvent.NoAction).isInstanceOf(Event::class.java) + assertThat(CarelevoConnectPrepareEvent.NoAction).isInstanceOf(Event::class.java) + assertThat(CarelevoConnectSafetyCheckEvent.NoAction).isInstanceOf(Event::class.java) + assertThat(CarelevoConnectNeedleEvent.NoAction).isInstanceOf(Event::class.java) + assertThat(AlarmEvent.NoAction).isInstanceOf(Event::class.java) + } + + // --- CarelevoOverviewEvent --------------------------------------------- + + @Test + fun `CarelevoOverviewEvent data objects are singletons`() { + assertThat(CarelevoOverviewEvent.NoAction).isSameInstanceAs(CarelevoOverviewEvent.NoAction) + assertThat(CarelevoOverviewEvent.ClickPumpStopResumeBtn) + .isSameInstanceAs(CarelevoOverviewEvent.ClickPumpStopResumeBtn) + } + + @Test + fun `CarelevoOverviewEvent members are distinct subtypes`() { + val all: List = listOf( + CarelevoOverviewEvent.NoAction, + CarelevoOverviewEvent.ShowMessageBluetoothNotEnabled, + CarelevoOverviewEvent.ShowMessageCarelevoIsNotConnected, + CarelevoOverviewEvent.DiscardFailed, + CarelevoOverviewEvent.ResumePumpFailed, + CarelevoOverviewEvent.StopPumpFailed, + CarelevoOverviewEvent.ClickPumpStopResumeBtn, + CarelevoOverviewEvent.ShowPumpStopDurationSelectDialog, + CarelevoOverviewEvent.ShowPumpResumeDialog, + CarelevoOverviewEvent.StartConnectionFlow, + CarelevoOverviewEvent.ShowPumpDiscardDialog + ) + assertThat(all.toSet()).hasSize(all.size) + assertThat(all).containsNoDuplicates() + } + + // --- CarelevoConnectEvent ---------------------------------------------- + + @Test + fun `CarelevoConnectEvent members are distinct singletons`() { + val all: List = listOf( + CarelevoConnectEvent.NoAction, + CarelevoConnectEvent.DiscardComplete, + CarelevoConnectEvent.DiscardFailed, + CarelevoConnectEvent.ExitFlow + ) + assertThat(all.toSet()).hasSize(all.size) + assertThat(CarelevoConnectEvent.ExitFlow).isSameInstanceAs(CarelevoConnectEvent.ExitFlow) + } + + // --- CarelevoConnectPrepareEvent --------------------------------------- + + @Test + fun `CarelevoConnectPrepareEvent members are distinct`() { + val all: List = listOf( + CarelevoConnectPrepareEvent.NoAction, + CarelevoConnectPrepareEvent.ShowConnectDialog, + CarelevoConnectPrepareEvent.ShowMessageScanFailed, + CarelevoConnectPrepareEvent.ShowMessageBluetoothNotEnabled, + CarelevoConnectPrepareEvent.ShowMessageScanIsWorking, + CarelevoConnectPrepareEvent.ShowMessageSelectedDeviceIseEmpty, + CarelevoConnectPrepareEvent.ShowMessageNotSetUserSettingInfo, + CarelevoConnectPrepareEvent.ConnectComplete, + CarelevoConnectPrepareEvent.ConnectFailed, + CarelevoConnectPrepareEvent.DiscardComplete, + CarelevoConnectPrepareEvent.DiscardFailed + ) + assertThat(all.toSet()).hasSize(all.size) + assertThat(all).containsNoDuplicates() + } + + // --- CarelevoConnectSafetyCheckEvent ----------------------------------- + + @Test + fun `CarelevoConnectSafetyCheckEvent members are distinct`() { + val all: List = listOf( + CarelevoConnectSafetyCheckEvent.NoAction, + CarelevoConnectSafetyCheckEvent.ShowMessageBluetoothNotEnabled, + CarelevoConnectSafetyCheckEvent.ShowMessageCarelevoIsNotConnected, + CarelevoConnectSafetyCheckEvent.SafetyCheckProgress, + CarelevoConnectSafetyCheckEvent.SafetyCheckComplete, + CarelevoConnectSafetyCheckEvent.SafetyCheckFailed, + CarelevoConnectSafetyCheckEvent.DiscardComplete, + CarelevoConnectSafetyCheckEvent.DiscardFailed + ) + assertThat(all.toSet()).hasSize(all.size) + assertThat(all).containsNoDuplicates() + } + + // --- CarelevoConnectNeedleEvent (with data classes) -------------------- + + @Test + fun `CarelevoConnectNeedleEvent data objects are distinct singletons`() { + val all: List = listOf( + CarelevoConnectNeedleEvent.NoAction, + CarelevoConnectNeedleEvent.ShowMessageBluetoothNotEnabled, + CarelevoConnectNeedleEvent.ShowMessageCarelevoIsNotConnected, + CarelevoConnectNeedleEvent.ShowMessageProfileNotSet, + CarelevoConnectNeedleEvent.CheckNeedleError, + CarelevoConnectNeedleEvent.DiscardComplete, + CarelevoConnectNeedleEvent.DiscardFailed, + CarelevoConnectNeedleEvent.SetBasalComplete, + CarelevoConnectNeedleEvent.SetBasalFailed + ) + assertThat(all.toSet()).hasSize(all.size) + } + + @Test + fun `CheckNeedleComplete carries its boolean result`() { + assertThat(CarelevoConnectNeedleEvent.CheckNeedleComplete(true).result).isTrue() + assertThat(CarelevoConnectNeedleEvent.CheckNeedleComplete(false).result).isFalse() + } + + @Test + fun `CheckNeedleComplete equality and copy behave as a data class`() { + val a = CarelevoConnectNeedleEvent.CheckNeedleComplete(true) + val b = CarelevoConnectNeedleEvent.CheckNeedleComplete(true) + assertThat(a).isEqualTo(b) + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + assertThat(a.copy(result = false)).isEqualTo(CarelevoConnectNeedleEvent.CheckNeedleComplete(false)) + assertThat(a).isNotEqualTo(CarelevoConnectNeedleEvent.CheckNeedleComplete(false)) + assertThat(a.component1()).isTrue() + } + + @Test + fun `CheckNeedleFailed carries its failed count`() { + val e = CarelevoConnectNeedleEvent.CheckNeedleFailed(3) + assertThat(e.failedCount).isEqualTo(3) + assertThat(e).isEqualTo(CarelevoConnectNeedleEvent.CheckNeedleFailed(3)) + assertThat(e).isNotEqualTo(CarelevoConnectNeedleEvent.CheckNeedleFailed(4)) + assertThat(e.copy(failedCount = 0).failedCount).isEqualTo(0) + } + + @Test + fun `CheckNeedleComplete and CheckNeedleFailed are different subtypes`() { + val complete: CarelevoConnectNeedleEvent = CarelevoConnectNeedleEvent.CheckNeedleComplete(true) + val failed: CarelevoConnectNeedleEvent = CarelevoConnectNeedleEvent.CheckNeedleFailed(1) + assertThat(complete).isNotEqualTo(failed) + } + + // --- AlarmEvent (with data classes) ------------------------------------ + + @Test + fun `AlarmEvent data objects are distinct singletons`() { + val all: List = listOf( + AlarmEvent.NoAction, + AlarmEvent.RequestBluetoothEnable, + AlarmEvent.Mute, + AlarmEvent.Mute5min, + AlarmEvent.StartAlarm + ) + assertThat(all.toSet()).hasSize(all.size) + assertThat(AlarmEvent.Mute5min).isSameInstanceAs(AlarmEvent.Mute5min) + } + + @Test + fun `ClearAlarm carries its alarm info`() { + val info = alarmInfo() + val event = AlarmEvent.ClearAlarm(info) + assertThat(event.info).isSameInstanceAs(info) + assertThat(event).isEqualTo(AlarmEvent.ClearAlarm(info)) + assertThat(event.component1()).isSameInstanceAs(info) + } + + @Test + fun `ClearAlarm distinguishes different alarm infos`() { + val a = AlarmEvent.ClearAlarm(alarmInfo(AlarmCause.ALARM_ALERT_OUT_OF_INSULIN)) + val b = AlarmEvent.ClearAlarm(alarmInfo(AlarmCause.ALARM_WARNING_PUMP_CLOGGED)) + assertThat(a).isNotEqualTo(b) + assertThat(a.copy(info = b.info)).isEqualTo(b) + } + + @Test + fun `ShowToastMessage carries its message resource id`() { + val event = AlarmEvent.ShowToastMessage(42) + assertThat(event.messageRes).isEqualTo(42) + assertThat(event).isEqualTo(AlarmEvent.ShowToastMessage(42)) + assertThat(event).isNotEqualTo(AlarmEvent.ShowToastMessage(43)) + assertThat(event.copy(messageRes = 7).messageRes).isEqualTo(7) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/type/CarelevoPatchStepTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/type/CarelevoPatchStepTest.kt new file mode 100644 index 000000000000..680a8663b11d --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/type/CarelevoPatchStepTest.kt @@ -0,0 +1,48 @@ +package app.aaps.pump.carelevo.presentation.type + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class CarelevoPatchStepTest { + + @Test + fun `has exactly nine steps in declaration order`() { + assertThat(CarelevoPatchStep.entries).containsExactly( + CarelevoPatchStep.PROFILE_GATE, + CarelevoPatchStep.SELECT_INSULIN, + CarelevoPatchStep.PATCH_START, + CarelevoPatchStep.SET_AMOUNT, + CarelevoPatchStep.PATCH_CONNECT, + CarelevoPatchStep.SAFETY_CHECK, + CarelevoPatchStep.SITE_LOCATION, + CarelevoPatchStep.PATCH_ATTACH, + CarelevoPatchStep.NEEDLE_INSERTION + ).inOrder() + } + + @Test + fun `ordinals are contiguous from zero`() { + assertThat(CarelevoPatchStep.PROFILE_GATE.ordinal).isEqualTo(0) + assertThat(CarelevoPatchStep.SELECT_INSULIN.ordinal).isEqualTo(1) + assertThat(CarelevoPatchStep.PATCH_START.ordinal).isEqualTo(2) + assertThat(CarelevoPatchStep.SET_AMOUNT.ordinal).isEqualTo(3) + assertThat(CarelevoPatchStep.PATCH_CONNECT.ordinal).isEqualTo(4) + assertThat(CarelevoPatchStep.SAFETY_CHECK.ordinal).isEqualTo(5) + assertThat(CarelevoPatchStep.SITE_LOCATION.ordinal).isEqualTo(6) + assertThat(CarelevoPatchStep.PATCH_ATTACH.ordinal).isEqualTo(7) + assertThat(CarelevoPatchStep.NEEDLE_INSERTION.ordinal).isEqualTo(8) + } + + @Test + fun `valueOf resolves each name`() { + CarelevoPatchStep.entries.forEach { step -> + assertThat(CarelevoPatchStep.valueOf(step.name)).isEqualTo(step) + } + } + + @Test + fun `valueOf rejects an unknown name`() { + assertFailsWith { CarelevoPatchStep.valueOf("NOT_A_STEP") } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/type/CarelevoScreenTypeTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/type/CarelevoScreenTypeTest.kt new file mode 100644 index 000000000000..d809d4fb78c9 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/type/CarelevoScreenTypeTest.kt @@ -0,0 +1,38 @@ +package app.aaps.pump.carelevo.presentation.type + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import kotlin.test.assertFailsWith + +internal class CarelevoScreenTypeTest { + + @Test + fun `has exactly four screens in declaration order`() { + assertThat(CarelevoScreenType.entries).containsExactly( + CarelevoScreenType.CONNECTION_FLOW_START, + CarelevoScreenType.PATCH_DISCARD, + CarelevoScreenType.SAFETY_CHECK, + CarelevoScreenType.NEEDLE_INSERTION + ).inOrder() + } + + @Test + fun `ordinals are contiguous from zero`() { + assertThat(CarelevoScreenType.CONNECTION_FLOW_START.ordinal).isEqualTo(0) + assertThat(CarelevoScreenType.PATCH_DISCARD.ordinal).isEqualTo(1) + assertThat(CarelevoScreenType.SAFETY_CHECK.ordinal).isEqualTo(2) + assertThat(CarelevoScreenType.NEEDLE_INSERTION.ordinal).isEqualTo(3) + } + + @Test + fun `valueOf resolves each name`() { + CarelevoScreenType.entries.forEach { type -> + assertThat(CarelevoScreenType.valueOf(type.name)).isEqualTo(type) + } + } + + @Test + fun `valueOf rejects an unknown name`() { + assertFailsWith { CarelevoScreenType.valueOf("NOPE") } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoAlarmViewModelTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoAlarmViewModelTest.kt new file mode 100644 index 000000000000..f630b88a2ff7 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoAlarmViewModelTest.kt @@ -0,0 +1,382 @@ +package app.aaps.pump.carelevo.presentation.viewmodel + +import android.os.Handler +import android.os.Looper +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.ui.UiInteraction +import app.aaps.core.ui.R as CoreUiR +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.common.CarelevoAlarmActionHandler +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.presentation.model.AlarmEvent +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +/** + * Unit tests for [CarelevoAlarmViewModel]. + * + * The ViewModel is a thin UI shell that owns ONLY the alarm-sound lifecycle and forwards clear + * requests to [CarelevoAlarmActionHandler], so everything asserted here is either a delegation to + * the mocked handler, a sound side effect on the mocked [UiInteraction], or a scheduling call on the + * private re-arm [Handler]. + * + * ## Why plain JVM (JUnit5) and not Robolectric + * The VM never uses `viewModelScope`; its only Android surface is the dedicated + * `HandlerThread`/[Handler] it builds in its initializer for the "mute 5 min" re-arm. That + * initializer runs fine on the JVM because the module sets `unitTests.isReturnDefaultValues = true` + * — the mockable android.jar rewrites `HandlerThread`/[Handler] bodies to return defaults, so + * construction is a no-op rather than a `Stub!` throw. + * + * Running on the JVM is deliberate and load-bearing for coverage: classes loaded through + * Robolectric's instrumenting sandbox classloader are NOT recorded by the JaCoCo agent in this + * build, so a Robolectric suite reports 0% no matter how much it exercises (the same VM previously + * had a green Robolectric suite and still measured 0/37 lines). + * + * ## How the re-arm Handler is driven + * `handler.looper.quitSafely()` in `onCleared` would NPE against the defaulted stub (`getLooper()` + * returns null), and a stubbed `postDelayed` never runs its [Runnable]. So after construction the + * private `handler` field is swapped for a Mockito mock whose looper is also mocked. That makes the + * scheduling observable (`postDelayed` args are captured and the [Runnable] invoked directly, which + * covers the re-arm lambda body) and lets `onCleared` be verified precisely — strictly more of the + * VM's own logic than waiting on a real looper would exercise. + */ +class CarelevoAlarmViewModelTest { + + private lateinit var aapsLogger: AAPSLogger + private lateinit var uiInteraction: UiInteraction + private lateinit var rh: ResourceHelper + private lateinit var alarmActionHandler: CarelevoAlarmActionHandler + + // Stand-ins for the dedicated re-arm thread the VM builds in its initializer. + private lateinit var handler: Handler + private lateinit var looper: Looper + + // Backing flows the mocked handler hands out; the shell must re-expose these very instances. + private val alarmQueueFlow: MutableStateFlow> = MutableStateFlow(emptyList()) + private val emptyEventFlow: MutableSharedFlow = MutableSharedFlow() + private val uiRequestsFlow: MutableSharedFlow = MutableSharedFlow() + + private lateinit var sut: CarelevoAlarmViewModel + + /** The delay the VM posts the "mute 5 min" re-arm with — `T.mins(5).msecs()`, spelled out here. */ + private val reArmDelayMs = 300_000L + + private fun alarm( + cause: AlarmCause = AlarmCause.ALARM_NOTICE_LGS_START, + id: String = "alarm-1" + ): CarelevoAlarmInfo = + CarelevoAlarmInfo( + alarmId = id, + alarmType = cause.alarmType, + cause = cause, + value = null, + createdAt = "2026-07-16T10:00:00", + updatedAt = "2026-07-16T10:00:00", + isAcknowledged = false + ) + + @BeforeEach + fun setUp() { + aapsLogger = mock() + uiInteraction = mock() + rh = mock() + alarmActionHandler = mock() + + // Read once at construction time (they become `val`s on the shell). + whenever(alarmActionHandler.alarmQueue).thenReturn(alarmQueueFlow) + whenever(alarmActionHandler.alarmQueueEmptyEvent).thenReturn(emptyEventFlow) + whenever(alarmActionHandler.uiRequests).thenReturn(uiRequestsFlow) + + sut = CarelevoAlarmViewModel(aapsLogger, uiInteraction, rh, alarmActionHandler) + + // Build the looper mock BEFORE the whenever() that hands it out (avoids UnfinishedStubbing). + looper = mock() + handler = mock() + whenever(handler.looper).thenReturn(looper) + replaceHandler(sut, handler) + } + + // ---- reflection helpers (private members the shell owns) ----------------------------------- + + /** Swap the VM's real (defaulted-stub) re-arm Handler for an observable mock. */ + private fun replaceHandler(vm: CarelevoAlarmViewModel, replacement: Handler) { + val field = CarelevoAlarmViewModel::class.java.getDeclaredField("handler") + field.isAccessible = true + field.set(vm, replacement) + } + + /** `onCleared` is protected on ViewModel and the VM is final, so drive it reflectively. */ + private fun invokeOnCleared(vm: CarelevoAlarmViewModel) { + val method = CarelevoAlarmViewModel::class.java.getDeclaredMethod("onCleared") + method.isAccessible = true + method.invoke(vm) + } + + /** The re-arm [Runnable] the VM posted for the 5-minute mute, so it can be fired on demand. */ + private fun capturePostedReArm(): Runnable { + val captor = argumentCaptor() + verify(handler).postDelayed(captor.capture(), eq(reArmDelayMs)) + return captor.firstValue + } + + // ---- delegation / exposed state ----------------------------------------------------------- + + @Test + fun `re-exposes the handler queue empty-event and ui-request flows unchanged`() { + assertThat(sut.alarmQueue).isSameInstanceAs(alarmQueueFlow) + assertThat(sut.alarmQueueEmptyEvent).isSameInstanceAs(emptyEventFlow) + assertThat(sut.event).isSameInstanceAs(uiRequestsFlow) + } + + @Test + fun `alarmQueue reflects values pushed by the action handler`() { + val queued = listOf(alarm()) + + alarmQueueFlow.value = queued + + assertThat(sut.alarmQueue.value).isEqualTo(queued) + } + + @Test + fun `alarmInfo defaults to null`() { + assertThat(sut.alarmInfo).isNull() + } + + @Test + fun `alarmInfo is settable`() { + val info = alarm() + + sut.alarmInfo = info + + assertThat(sut.alarmInfo).isSameInstanceAs(info) + } + + @Test + fun `loadActiveAlarms delegates to the action handler`() { + sut.loadActiveAlarms() + + verify(alarmActionHandler).loadActiveAlarms() + } + + // ---- triggerEvent: ClearAlarm ------------------------------------------------------------- + + @Test + fun `triggerEvent ClearAlarm stops the alarm stores the info and forwards to the handler`() { + val info = alarm() + val event = AlarmEvent.ClearAlarm(info) + + sut.triggerEvent(event) + + verify(uiInteraction).stopAlarm("Confirm Click") + assertThat(sut.alarmInfo).isSameInstanceAs(info) + verify(alarmActionHandler).triggerEvent(event) + } + + @Test + fun `triggerEvent ClearAlarm neither sounds the alarm nor schedules a re-arm`() { + sut.triggerEvent(AlarmEvent.ClearAlarm(alarm())) + + verify(uiInteraction, never()).runAlarm(any(), any(), any()) + verify(handler, never()).postDelayed(any(), any()) + } + + @Test + fun `triggerEvent ClearAlarm overwrites a previously stored alarmInfo`() { + sut.alarmInfo = alarm(id = "old") + val current = alarm(id = "new") + + sut.triggerEvent(AlarmEvent.ClearAlarm(current)) + + assertThat(sut.alarmInfo).isSameInstanceAs(current) + } + + // ---- triggerEvent: Mute ------------------------------------------------------------------- + + @Test + fun `triggerEvent Mute only stops the alarm and does not touch the handler`() { + sut.triggerEvent(AlarmEvent.Mute) + + verify(uiInteraction).stopAlarm("Mute Click") + verify(uiInteraction, never()).runAlarm(any(), any(), any()) + verify(alarmActionHandler, never()).triggerEvent(any()) + verify(handler, never()).postDelayed(any(), any()) + } + + @Test + fun `triggerEvent Mute leaves alarmInfo untouched`() { + val info = alarm() + sut.alarmInfo = info + + sut.triggerEvent(AlarmEvent.Mute) + + assertThat(sut.alarmInfo).isSameInstanceAs(info) + } + + // ---- triggerEvent: Mute5min --------------------------------------------------------------- + + @Test + fun `triggerEvent Mute5min stops the alarm and schedules the re-arm five minutes out`() { + sut.triggerEvent(AlarmEvent.Mute5min) + + verify(uiInteraction).stopAlarm("Mute5min Click") + verify(handler).postDelayed(any(), eq(reArmDelayMs)) + } + + @Test + fun `triggerEvent Mute5min does not sound the alarm before the delay elapses`() { + sut.triggerEvent(AlarmEvent.Mute5min) + + // The re-arm is a delayed post; until that Runnable runs, nothing may sound. + verify(uiInteraction, never()).runAlarm(any(), any(), any()) + } + + @Test + fun `the Mute5min re-arm runs the alarm using the app title when no alarm info is set`() { + whenever(rh.gs(R.string.carelevo)).thenReturn("Carelevo") + sut.triggerEvent(AlarmEvent.Mute5min) + + capturePostedReArm().run() + + // alarmInfo is null -> status falls back to the app title. + verify(uiInteraction).runAlarm(eq("Carelevo"), eq("Carelevo"), eq(CoreUiR.raw.error)) + } + + @Test + fun `the Mute5min re-arm runs the alarm using the cause title when alarm info is set`() { + whenever(rh.gs(R.string.carelevo)).thenReturn("Carelevo") + whenever(rh.gs(R.string.alarm_feat_title_notice_lgs_started)).thenReturn("LGS Started") + sut.alarmInfo = alarm(AlarmCause.ALARM_NOTICE_LGS_START) + sut.triggerEvent(AlarmEvent.Mute5min) + + capturePostedReArm().run() + + verify(uiInteraction).runAlarm(eq("LGS Started"), eq("Carelevo"), eq(CoreUiR.raw.error)) + } + + // ---- triggerEvent: StartAlarm ------------------------------------------------------------- + + @Test + fun `triggerEvent StartAlarm with no alarm info runs the alarm using the app title as status`() { + whenever(rh.gs(R.string.carelevo)).thenReturn("Carelevo") + + sut.triggerEvent(AlarmEvent.StartAlarm) + + verify(uiInteraction).runAlarm(eq("Carelevo"), eq("Carelevo"), eq(CoreUiR.raw.error)) + } + + @Test + fun `triggerEvent StartAlarm with alarm info runs the alarm using the notice cause title`() { + whenever(rh.gs(R.string.carelevo)).thenReturn("Carelevo") + whenever(rh.gs(R.string.alarm_feat_title_notice_lgs_started)).thenReturn("LGS Started") + sut.alarmInfo = alarm(AlarmCause.ALARM_NOTICE_LGS_START) + + sut.triggerEvent(AlarmEvent.StartAlarm) + + verify(uiInteraction).runAlarm(eq("LGS Started"), eq("Carelevo"), eq(CoreUiR.raw.error)) + } + + @Test + fun `triggerEvent StartAlarm with a warning alarm info runs the alarm using the warning title`() { + whenever(rh.gs(R.string.carelevo)).thenReturn("Carelevo") + whenever(rh.gs(R.string.alarm_feat_title_warning_infusion_clogged)).thenReturn("Infusion clogged") + sut.alarmInfo = alarm(AlarmCause.ALARM_WARNING_PUMP_CLOGGED) + + sut.triggerEvent(AlarmEvent.StartAlarm) + + verify(uiInteraction).runAlarm(eq("Infusion clogged"), eq("Carelevo"), eq(CoreUiR.raw.error)) + } + + @Test + fun `triggerEvent StartAlarm does not stop the alarm schedule a re-arm or reach the handler`() { + whenever(rh.gs(R.string.carelevo)).thenReturn("Carelevo") + + sut.triggerEvent(AlarmEvent.StartAlarm) + + verify(uiInteraction, never()).stopAlarm(any()) + verify(handler, never()).postDelayed(any(), any()) + verify(alarmActionHandler, never()).triggerEvent(any()) + } + + @Test + fun `triggerEvent StartAlarm logs the resolved status on the pump-comm tag`() { + whenever(rh.gs(R.string.carelevo)).thenReturn("Carelevo") + + sut.triggerEvent(AlarmEvent.StartAlarm) + + verify(aapsLogger).debug(eq(LTag.PUMPCOMM), eq("startAlarm reason=start status=Carelevo")) + } + + // ---- triggerEvent: ignored events --------------------------------------------------------- + + @Test + fun `triggerEvent NoAction does nothing`() { + sut.triggerEvent(AlarmEvent.NoAction) + + verifyNoInteractions(uiInteraction) + verify(alarmActionHandler, never()).triggerEvent(any()) + assertThat(sut.alarmInfo).isNull() + } + + @Test + fun `triggerEvent RequestBluetoothEnable is ignored by the shell`() { + // Emitted by the action handler for the host to act on; the shell itself must stay inert. + sut.triggerEvent(AlarmEvent.RequestBluetoothEnable) + + verifyNoInteractions(uiInteraction) + verify(alarmActionHandler, never()).triggerEvent(any()) + } + + @Test + fun `triggerEvent ShowToastMessage is ignored by the shell`() { + sut.triggerEvent(AlarmEvent.ShowToastMessage(R.string.carelevo)) + + verifyNoInteractions(uiInteraction) + verify(alarmActionHandler, never()).triggerEvent(any()) + } + + // ---- onCleared ---------------------------------------------------------------------------- + + @Test + fun `onCleared drops pending callbacks and quits the handler looper`() { + invokeOnCleared(sut) + + verify(handler).removeCallbacksAndMessages(null) + verify(looper).quitSafely() + } + + @Test + fun `onCleared drops a pending Mute5min re-arm so the alarm never fires`() { + sut.triggerEvent(AlarmEvent.Mute5min) + + invokeOnCleared(sut) + + // Without this the HandlerThread leaks and the delayed re-arm could sound against a cleared VM. + verify(handler).removeCallbacksAndMessages(null) + verify(looper).quitSafely() + verify(uiInteraction, never()).runAlarm(any(), any(), any()) + } + + @Test + fun `onCleared is idempotent`() { + invokeOnCleared(sut) + invokeOnCleared(sut) + + verify(handler, times(2)).removeCallbacksAndMessages(null) + verify(looper, times(2)).quitSafely() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoOverviewViewModelTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoOverviewViewModelTest.kt new file mode 100644 index 000000000000..aaf3b1b5038c --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoOverviewViewModelTest.kt @@ -0,0 +1,1431 @@ +package app.aaps.pump.carelevo.presentation.viewmodel + +import android.content.Context +import androidx.lifecycle.viewModelScope +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.profile.Profile +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.queue.CustomCommand +import app.aaps.core.interfaces.resources.ResourceHelper +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.rx.bus.RxBus +import app.aaps.core.interfaces.rx.events.EventPumpStatusChanged +import app.aaps.core.interfaces.rx.events.EventQueueChanged +import app.aaps.core.interfaces.utils.DateUtil +import app.aaps.core.ui.R as CoreUiR +import app.aaps.core.ui.compose.StatusLevel +import app.aaps.core.ui.compose.pump.ActionCategory +import app.aaps.core.ui.compose.pump.PumpInfoRow +import app.aaps.pump.carelevo.R +import app.aaps.pump.carelevo.ble.CarelevoBleSession +import app.aaps.pump.carelevo.command.CmdDiscard +import app.aaps.pump.carelevo.command.CmdPumpResume +import app.aaps.pump.carelevo.command.CmdPumpStop +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.common.model.Event +import app.aaps.pump.carelevo.common.model.PatchState +import app.aaps.pump.carelevo.common.model.UiState +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoExtendBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoImmeBolusInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.infusion.CarelevoTempBasalInfusionInfoDomainModel +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.model.result.ResultSuccess +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseRequest +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import app.aaps.pump.carelevo.domain.usecase.infusion.CarelevoDeleteInfusionInfoUseCase +import app.aaps.pump.carelevo.domain.usecase.infusion.model.CarelevoDeleteInfusionRequestModel +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchForceDiscardUseCase +import app.aaps.pump.carelevo.presentation.model.CarelevoConnectEvent +import app.aaps.pump.carelevo.presentation.model.CarelevoOverviewEvent +import app.aaps.pump.carelevo.presentation.type.CarelevoScreenType +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.plugins.RxJavaPlugins +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.BehaviorSubject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.joda.time.DateTime +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.after +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.timeout +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.Locale +import java.util.Optional +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Unit tests for [CarelevoOverviewViewModel] — the Carelevo overview screen's state assembly, the + * pump stop/resume/discard flows and the status refresh path. + * + * **Robolectric, not plain JVM.** The ViewModel publishes most of its screen data through + * [androidx.lifecycle.MutableLiveData]. `LiveData.setValue` asserts it runs on the main thread via + * `ArchTaskExecutor` → `Looper.getMainLooper()`, which returns `null` under a plain JVM unit test + * (`isReturnDefaultValues`) and NPEs. Under [RobolectricTestRunner] the test thread *is* the main + * looper's thread, so every LiveData write executes for real — matching the sibling + * `CarelevoAlarmViewModelTest`/`CarelevoAlarmNotifierTest` in this module. The real + * [Context] is only handed to the shared `PumpCommunicationStatus`, which never touches it while its + * two [RxBus] flows stay empty. + * + * **Dispatcher strategy.** `Dispatchers.setMain(UnconfinedTestDispatcher())` makes `viewModelScope` + * eager: `triggerEvent`, `setUiState`, `startDiscardProcess`, `startPumpStopProcess`, + * `startPumpResume` and `refreshPatchInfusionInfo` all run their `launch { }` bodies in-line, so the + * terminal state can be asserted straight after the call. The suspend [CommandQueue]/[PumpSync] + * members are Mockito stubs and never really suspend, so nothing needs virtual time to be advanced. + * Deliberately **no `advanceUntilIdle()`**: `overviewUiState` combines an endless `tickerFlow(30 s)` + * whose `delay` is virtual, so advancing to idle would never terminate. The ticker's first emission + * is immediate, which is all the combine needs. + * + * Rx is pinned to [Schedulers.trampoline] through the mocked [AapsSchedulers], so `observePatchInfo` + * / `observePatchState` / `observeInfusionInfo` / `observeProfile` deliver synchronously on the test + * thread. Their sources are real [BehaviorSubject]s, which also serve the `.value` reads the VM does + * on the same properties. + * + * [ResourceHelper.gs] is answered with a deterministic `S(arg,arg)` encoding ([s]) so assembled + * row labels/values can be asserted exactly without depending on translations. + * + * The `secondTick` → `clearExpiredInfusions` loop in `init` runs on a real [Dispatchers.Default] + * clock (its `delay` is NOT virtual), so the two tests that cover expiry use Mockito's + * [timeout]/[after] verification modes rather than fake time. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +@OptIn(ExperimentalCoroutinesApi::class) +class CarelevoOverviewViewModelTest { + + // REAL application context — only stored by PumpCommunicationStatus, never dereferenced here. + private val context: Context = RuntimeEnvironment.getApplication() + + private lateinit var rh: ResourceHelper + private lateinit var pumpSync: PumpSync + private lateinit var dateUtil: DateUtil + private lateinit var commandQueue: CommandQueue + private lateinit var aapsLogger: AAPSLogger + private lateinit var carelevoPatch: CarelevoPatch + private lateinit var aapsSchedulers: AapsSchedulers + private lateinit var patchForceDiscardUseCase: CarelevoPatchForceDiscardUseCase + private lateinit var deleteInfusionInfoUseCase: CarelevoDeleteInfusionInfoUseCase + private lateinit var rxBus: RxBus + private lateinit var bleSession: CarelevoBleSession + + private lateinit var sut: CarelevoOverviewViewModel + + // Real subjects: the VM both subscribes to these AND reads their `.value` synchronously. + private val patchInfoSubject: BehaviorSubject> = BehaviorSubject.create() + private val patchStateSubject: BehaviorSubject> = BehaviorSubject.create() + private val infusionSubject: BehaviorSubject> = BehaviorSubject.create() + private val profileSubject: BehaviorSubject> = BehaviorSubject.create() + + // Live connection flows the VM reads from CarelevoBleSession; mutate them in a test to drive the rows. + private val connectedFlow = MutableStateFlow(false) + private val lastConnectedFlow = MutableStateFlow(0L) + + private val events = CopyOnWriteArrayList() + private lateinit var collectorScope: CoroutineScope + + /** Fixed "now" fed to the VM through [DateUtil.now]; the expiry maths is derived from it. */ + private val nowMillis = 1_752_000_000_000L + private val dayMillis = 24L * 60L * 60L * 1000L + + // ---- helpers ------------------------------------------------------------------------------ + + /** Mirror of the stubbed [ResourceHelper.gs] answers — lets tests assert exact assembled text. */ + private fun s(id: Int): String = "S$id" + private fun s(id: Int, vararg args: Any?): String = "S$id(${args.joinToString(",")})" + + private fun local(millis: Long): LocalDateTime = + LocalDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneId.systemDefault()) + + private fun uiFormat(ldt: LocalDateTime): String = ldt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + + private fun patchInfo( + manufactureNumber: String? = "SN-0001", + firmwareVersion: String? = "T168", + bootDateTimeUtcMillis: Long? = nowMillis - 2 * dayMillis, + bootDateTime: String? = null, + insulinAmount: Int? = 200, + insulinRemain: Double? = 150.5, + checkSafety: Boolean? = true, + checkNeedle: Boolean? = true, + needleFailedCount: Int? = 0, + isStopped: Boolean? = false, + infusedTotalBasalAmount: Double? = 1.234, + infusedTotalBolusAmount: Double? = 2.345, + mode: Int? = 1 + ): CarelevoPatchInfoDomainModel = CarelevoPatchInfoDomainModel( + address = "AA:BB:CC:DD:EE:FF", + manufactureNumber = manufactureNumber, + firmwareVersion = firmwareVersion, + bootDateTime = bootDateTime, + bootDateTimeUtcMillis = bootDateTimeUtcMillis, + insulinAmount = insulinAmount, + insulinRemain = insulinRemain, + checkSafety = checkSafety, + checkNeedle = checkNeedle, + needleFailedCount = needleFailedCount, + isStopped = isStopped, + infusedTotalBasalAmount = infusedTotalBasalAmount, + infusedTotalBolusAmount = infusedTotalBolusAmount, + mode = mode + ) + + private fun tempBasal( + speed: Double? = 1.2, + durationMin: Int? = null, + createdAt: DateTime = DateTime.now() + ) = CarelevoTempBasalInfusionInfoDomainModel( + infusionId = "temp", address = "A", mode = 2, createdAt = createdAt, + speed = speed, infusionDurationMin = durationMin + ) + + private fun immeBolus( + durationSeconds: Int? = null, + createdAt: DateTime = DateTime.now() + ) = CarelevoImmeBolusInfusionInfoDomainModel( + infusionId = "imme", address = "A", mode = 3, createdAt = createdAt, + volume = 1.0, infusionDurationSeconds = durationSeconds + ) + + private fun extendBolus( + durationMin: Int? = null, + createdAt: DateTime = DateTime.now() + ) = CarelevoExtendBolusInfusionInfoDomainModel( + infusionId = "extend", address = "A", mode = 5, createdAt = createdAt, + volume = 2.0, speed = 0.5, infusionDurationMin = durationMin + ) + + /** Build the stubbed result BEFORE the whenever() that returns it (UnfinishedStubbingException). */ + private fun stubCustomCommand(success: Boolean) { + val result = mock() + whenever(result.success).thenReturn(success) + whenever { commandQueue.customCommand(any()) }.thenReturn(result) + } + + private fun stubCancelTempBasal(success: Boolean) { + val result = mock() + whenever(result.success).thenReturn(success) + // 2 explicit matchers: `autoForced` has a default value, so the mock records both args. + whenever { commandQueue.cancelTempBasal(any(), any()) }.thenReturn(result) + } + + private fun stubCancelExtended(success: Boolean) { + val result = mock() + whenever(result.success).thenReturn(success) + whenever { commandQueue.cancelExtended() }.thenReturn(result) + } + + private fun infoRows(): List = sut.overviewUiState.value.infoRows.map { it as PumpInfoRow } + + // ---- setup -------------------------------------------------------------------------------- + + @Before + fun setUp() { + rh = mock() + pumpSync = mock() + dateUtil = mock() + commandQueue = mock() + aapsLogger = mock() + carelevoPatch = mock() + aapsSchedulers = mock() + patchForceDiscardUseCase = mock() + deleteInfusionInfoUseCase = mock() + rxBus = mock() + bleSession = mock() + + // Main must be a test dispatcher BEFORE construction: viewModelScope resolves it on creation. + Dispatchers.setMain(UnconfinedTestDispatcher()) + // `clearInfusionInfo` subscribes with both arms, but an errored patchInfo/profile stream ends + // in `subscribe { }` consumers without an onError arm → swallow the global Rx rethrow. + RxJavaPlugins.setErrorHandler { } + + whenever(aapsSchedulers.io).thenReturn(Schedulers.trampoline()) + whenever(aapsSchedulers.main).thenReturn(Schedulers.trampoline()) + whenever(dateUtil.now()).thenReturn(nowMillis) + + // PumpCommunicationStatus subscribes to both in its init; empty flows keep banner/queue null. + whenever(rxBus.toFlow(EventPumpStatusChanged::class.java)).thenReturn(emptyFlow()) + whenever(rxBus.toFlow(EventQueueChanged::class.java)).thenReturn(emptyFlow()) + + whenever(carelevoPatch.patchInfo).thenReturn(patchInfoSubject) + whenever(carelevoPatch.patchState).thenReturn(patchStateSubject) + whenever(carelevoPatch.infusionInfo).thenReturn(infusionSubject) + whenever(carelevoPatch.profile).thenReturn(profileSubject) + + whenever(bleSession.connected).thenReturn(connectedFlow) + whenever(bleSession.lastConnectedAt).thenReturn(lastConnectedFlow) + + // Deterministic, translation-independent resource text (see `s`). + whenever(rh.gs(any())).thenAnswer { inv -> "S${inv.getArgument(0)}" } + whenever(rh.gs(any(), anyOrNull())) + .thenAnswer { inv -> "S${inv.getArgument(0)}(${inv.getArgument(1)})" } + whenever(rh.gs(any(), anyOrNull(), anyOrNull())) + .thenAnswer { inv -> "S${inv.getArgument(0)}(${inv.getArgument(1)},${inv.getArgument(2)})" } + whenever(rh.gs(any(), anyOrNull(), anyOrNull(), anyOrNull())) + .thenAnswer { inv -> + "S${inv.getArgument(0)}(${inv.getArgument(1)},${inv.getArgument(2)},${inv.getArgument(3)})" + } + + // Default: the delete use case succeeds. The `init` second-tick may reach it at any moment, + // and an unstubbed (null) Single would NPE on a background thread. + whenever(deleteInfusionInfoUseCase.execute(any())) + .thenReturn(Single.just(ResponseResult.Success(ResultSuccess))) + + sut = CarelevoOverviewViewModel( + rh = rh, + pumpSync = pumpSync, + dateUtil = dateUtil, + commandQueue = commandQueue, + aapsLogger = aapsLogger, + carelevoPatch = carelevoPatch, + bleSession = bleSession, + aapsSchedulers = aapsSchedulers, + patchForceDiscardUseCase = patchForceDiscardUseCase, + carelevoDeleteInfusionInfoUseCase = deleteInfusionInfoUseCase, + rxBus = rxBus, + context = context + ) + + collectorScope = CoroutineScope(Dispatchers.Unconfined) + // `event` is a consume-once EventFlow → exactly one collector may own it. + collectorScope.launch { sut.event.collect { events.add(it) } } + // overviewUiState is stateIn(WhileSubscribed) → it only recomputes while someone subscribes. + collectorScope.launch { sut.overviewUiState.collect { } } + } + + @After + fun tearDown() { + collectorScope.cancel() + // `onCleared` is protected, so cancel the scope directly: without this the endless second-tick + // collector outlives the test and resumes onto a dispatcher that no longer exists. Join rather + // than just cancel — cancel() only *requests* it, and an in-flight tick still resuming while + // resetMain() swaps the dispatcher out trips "Main is used concurrently with setting it". + runBlocking { sut.viewModelScope.coroutineContext.job.cancelAndJoin() } + RxJavaPlugins.reset() + Dispatchers.resetMain() + } + + // ---- initial state ------------------------------------------------------------------------ + + @Test + fun `uiState starts Idle`() { + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `patchState starts NotConnectedNotBooting`() { + assertThat(sut.patchState.value).isEqualTo(PatchState.NotConnectedNotBooting) + } + + @Test + fun `isCheckScreen starts null`() { + assertThat(sut.isCheckScreen.value).isNull() + } + + @Test + fun `isPumpStop starts false`() { + assertThat(sut.isPumpStop.value).isFalse() + } + + @Test + fun `hasUnacknowledgedAlarms starts false and initUnacknowledgedAlarms resets it`() { + assertThat(sut.hasUnacknowledgedAlarms.value).isFalse() + + sut.initUnacknowledgedAlarms() + + assertThat(sut.hasUnacknowledgedAlarms.value).isFalse() + } + + @Test + fun `setIsCreated flips the created latch`() { + assertThat(sut.isCreated).isFalse() + + sut.setIsCreated(true) + assertThat(sut.isCreated).isTrue() + + sut.setIsCreated(false) + assertThat(sut.isCreated).isFalse() + } + + // ---- parseBootDateTime(String?) ----------------------------------------------------------- + + @Test + fun `parseBootDateTime returns null for a null raw string`() { + assertThat(sut.parseBootDateTime(null as String?)).isNull() + } + + @Test + fun `parseBootDateTime returns null for a blank raw string`() { + assertThat(sut.parseBootDateTime(" ")).isNull() + } + + @Test + fun `parseBootDateTime parses the yyMMddHHmm patch format`() { + assertThat(sut.parseBootDateTime("2601011230")).isEqualTo(LocalDateTime.of(2026, 1, 1, 12, 30)) + } + + @Test + fun `parseBootDateTime returns null for an unparseable raw string`() { + assertThat(sut.parseBootDateTime("not-a-date")).isNull() + } + + // ---- parseBootDateTime(Long?) ------------------------------------------------------------- + + @Test + fun `parseBootDateTime returns null for null utc millis`() { + assertThat(sut.parseBootDateTime(null as Long?)).isNull() + } + + @Test + fun `parseBootDateTime converts utc millis into the system zone`() { + assertThat(sut.parseBootDateTime(nowMillis)).isEqualTo(local(nowMillis)) + } + + // ---- observePatchInfo --------------------------------------------------------------------- + + @Test + fun `observePatchInfo maps a report onto every overview field`() { + val bootMillis = nowMillis - 2 * dayMillis + val bootLdt = local(bootMillis) + sut.observePatchInfo() + + patchInfoSubject.onNext(Optional.of(patchInfo(bootDateTimeUtcMillis = bootMillis))) + + assertThat(sut.serialNumber.value).isEqualTo("SN-0001") + assertThat(sut.lotNumber.value).isEqualTo("T168") + assertThat(sut.bootDateTime.value).isEqualTo(uiFormat(bootLdt)) + assertThat(sut.expirationTime.value).isEqualTo(uiFormat(bootLdt.plusDays(7))) + // 1.234 + 2.345 rounded HALF_UP to 2dp each = 1.23 + 2.35 = 3.58 + assertThat(sut.totalInsulinAmount.value).isEqualTo(3.58) + assertThat(sut.isPumpStop.value).isFalse() + assertThat(sut.insulinRemains.value).isEqualTo( + s( + R.string.carelevo_insulin_remain_value, + s(R.string.common_label_unit_value_dose_with_space, 150.5), + s(R.string.common_label_unit_value_dose_with_space, 200) + ) + ) + val expectedRemain = ChronoUnit.MINUTES.between(local(nowMillis), bootLdt.plusDays(7)).toInt() + assertThat(sut.runningRemainMinutes.value).isEqualTo(expectedRemain) + assertThat(expectedRemain).isGreaterThan(0) + } + + @Test + fun `observePatchInfo falls back to the yyMMddHHmm boot string when utc millis are missing`() { + sut.observePatchInfo() + + patchInfoSubject.onNext(Optional.of(patchInfo(bootDateTimeUtcMillis = null, bootDateTime = "2601011230"))) + + assertThat(sut.bootDateTime.value).isEqualTo("2026-01-01 12:30") + assertThat(sut.expirationTime.value).isEqualTo("2026-01-08 12:30") + } + + @Test + fun `observePatchInfo leaves boot fields empty when no boot time is known at all`() { + sut.observePatchInfo() + + patchInfoSubject.onNext(Optional.of(patchInfo(bootDateTimeUtcMillis = null, bootDateTime = null))) + + assertThat(sut.bootDateTime.value).isEqualTo("") + assertThat(sut.expirationTime.value).isEqualTo("") + assertThat(sut.runningRemainMinutes.value).isEqualTo(0) + } + + @Test + fun `observePatchInfo blanks the insulin remain text when either amount is unknown`() { + sut.observePatchInfo() + + patchInfoSubject.onNext(Optional.of(patchInfo(insulinRemain = null))) + + assertThat(sut.insulinRemains.value).isEqualTo("") + } + + @Test + fun `observePatchInfo defaults missing infused totals and stop flag`() { + sut.observePatchInfo() + + patchInfoSubject.onNext( + Optional.of( + patchInfo( + manufactureNumber = null, firmwareVersion = null, + infusedTotalBasalAmount = null, infusedTotalBolusAmount = null, isStopped = null + ) + ) + ) + + assertThat(sut.serialNumber.value).isEqualTo("") + assertThat(sut.lotNumber.value).isEqualTo("") + assertThat(sut.totalInsulinAmount.value).isEqualTo(0.0) + assertThat(sut.isPumpStop.value).isFalse() + } + + @Test + fun `observePatchInfo reports a positive overdue countdown once the patch is past expiry`() { + val bootMillis = nowMillis - 8 * dayMillis + val bootLdt = local(bootMillis) + sut.observePatchInfo() + + patchInfoSubject.onNext(Optional.of(patchInfo(bootDateTimeUtcMillis = bootMillis))) + + // now is after boot+7d → the countdown flips to "time since expiry" and expiry gains 12 h. + val expectedRemain = ChronoUnit.MINUTES.between(bootLdt.plusDays(7), local(nowMillis)).toInt() + assertThat(sut.runningRemainMinutes.value).isEqualTo(expectedRemain) + assertThat(expectedRemain).isGreaterThan(0) + assertThat(sut.expirationTime.value).isEqualTo(uiFormat(bootLdt.plusDays(7).plusHours(12))) + } + + @Test + fun `observePatchInfo skips a null report and clears the check screen`() { + sut.observePatchInfo() + patchInfoSubject.onNext(Optional.of(patchInfo(checkSafety = null))) + assertThat(sut.isCheckScreen.value).isEqualTo(CarelevoScreenType.SAFETY_CHECK) + + patchInfoSubject.onNext(Optional.empty()) + + assertThat(sut.isCheckScreen.value).isNull() + // The empty report never reaches updateState — the last known values survive. + assertThat(sut.serialNumber.value).isEqualTo("SN-0001") + } + + @Test + fun `observePatchInfo survives an errored stream`() { + sut.observePatchInfo() + + patchInfoSubject.onError(RuntimeException("boom")) + + assertThat(sut.isCheckScreen.value).isNull() + assertThat(sut.serialNumber.value).isNull() + } + + // ---- updateCheckScreen branches ----------------------------------------------------------- + + @Test + fun `check screen asks for needle insertion while retries remain`() { + sut.observePatchInfo() + + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = false, needleFailedCount = 2))) + + assertThat(sut.isCheckScreen.value).isEqualTo(CarelevoScreenType.NEEDLE_INSERTION) + } + + @Test + fun `check screen clears once the needle retry budget is spent`() { + sut.observePatchInfo() + + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = false, needleFailedCount = 3))) + + assertThat(sut.isCheckScreen.value).isNull() + } + + @Test + fun `check screen clears when the needle failure count is unknown`() { + sut.observePatchInfo() + + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = false, needleFailedCount = null))) + + assertThat(sut.isCheckScreen.value).isNull() + } + + @Test + fun `check screen asks for the safety check when it has not run yet`() { + sut.observePatchInfo() + + patchInfoSubject.onNext(Optional.of(patchInfo(checkSafety = null, checkNeedle = null))) + + assertThat(sut.isCheckScreen.value).isEqualTo(CarelevoScreenType.SAFETY_CHECK) + } + + @Test + fun `check screen asks for the safety check when it passed but the needle is unknown`() { + sut.observePatchInfo() + + patchInfoSubject.onNext(Optional.of(patchInfo(checkSafety = true, checkNeedle = null))) + + assertThat(sut.isCheckScreen.value).isEqualTo(CarelevoScreenType.SAFETY_CHECK) + } + + @Test + fun `check screen is clear for a fully activated patch`() { + sut.observePatchInfo() + + patchInfoSubject.onNext(Optional.of(patchInfo(checkSafety = true, checkNeedle = true))) + + assertThat(sut.isCheckScreen.value).isNull() + } + + @Test + fun `check screen is clear when the safety check failed but the needle is known`() { + sut.observePatchInfo() + + patchInfoSubject.onNext(Optional.of(patchInfo(checkSafety = false, checkNeedle = true))) + + assertThat(sut.isCheckScreen.value).isNull() + } + + // ---- observePatchState -------------------------------------------------------------------- + + @Test + fun `observePatchState publishes a booted state and pulls the basal rate from the profile`() { + val profile = mock() + whenever(profile.getBasal()).thenReturn(1.75) + profileSubject.onNext(Optional.of(profile)) + sut.observePatchState() + + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + + assertThat(sut.patchState.value).isEqualTo(PatchState.ConnectedBooted) + assertThat(sut.basalRate.value).isEqualTo(1.75) + } + + @Test + fun `observePatchState falls back to a zero basal rate when no profile is set`() { + sut.observePatchState() + + patchStateSubject.onNext(Optional.of(PatchState.NotConnectedBooted)) + + assertThat(sut.patchState.value).isEqualTo(PatchState.NotConnectedBooted) + assertThat(sut.basalRate.value).isEqualTo(0.0) + } + + @Test + fun `observePatchState wipes every overview field when the patch is gone`() { + val profile = mock() + whenever(profile.getBasal()).thenReturn(1.75) + profileSubject.onNext(Optional.of(profile)) + sut.observePatchInfo() + sut.observePatchState() + sut.observeInfusionInfo() + patchInfoSubject.onNext(Optional.of(patchInfo(isStopped = true))) + infusionSubject.onNext(Optional.of(CarelevoInfusionInfoDomainModel(tempBasalInfusionInfo = tempBasal(speed = 2.5)))) + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + assertThat(sut.basalRate.value).isEqualTo(1.75) + assertThat(sut.tempBasalRate.value).isEqualTo(2.5) + + patchStateSubject.onNext(Optional.of(PatchState.NotConnectedNotBooting)) + + assertThat(sut.patchState.value).isEqualTo(PatchState.NotConnectedNotBooting) + assertThat(sut.serialNumber.value).isEqualTo("") + assertThat(sut.lotNumber.value).isEqualTo("") + assertThat(sut.bootDateTime.value).isEqualTo("") + assertThat(sut.expirationTime.value).isEqualTo("") + assertThat(sut.insulinRemains.value).isEqualTo("") + assertThat(sut.totalInsulinAmount.value).isEqualTo(0.0) + assertThat(sut.isPumpStop.value).isFalse() + assertThat(sut.runningRemainMinutes.value).isEqualTo(0) + assertThat(sut.tempBasalRate.value).isNull() + assertThat(sut.basalRate.value).isEqualTo(0.0) + } + + @Test + fun `observePatchState ignores an empty state report`() { + sut.observePatchState() + + patchStateSubject.onNext(Optional.empty()) + + assertThat(sut.patchState.value).isEqualTo(PatchState.NotConnectedNotBooting) + assertThat(sut.basalRate.value).isNull() + } + + @Test + fun `observePatchState survives an errored stream`() { + sut.observePatchState() + + patchStateSubject.onError(RuntimeException("boom")) + + assertThat(sut.patchState.value).isEqualTo(PatchState.NotConnectedNotBooting) + } + + // ---- observeProfile ----------------------------------------------------------------------- + + @Test + fun `observeProfile republishes the basal rate`() { + val profile = mock() + whenever(profile.getBasal()).thenReturn(0.85) + sut.observeProfile() + + profileSubject.onNext(Optional.of(profile)) + + assertThat(sut.basalRate.value).isEqualTo(0.85) + } + + @Test + fun `observeProfile zeroes the basal rate when the profile is cleared`() { + sut.observeProfile() + + profileSubject.onNext(Optional.empty()) + + assertThat(sut.basalRate.value).isEqualTo(0.0) + } + + // ---- observeInfusionInfo ------------------------------------------------------------------ + + @Test + fun `observeInfusionInfo publishes the running temp basal speed`() { + sut.observeInfusionInfo() + + infusionSubject.onNext(Optional.of(CarelevoInfusionInfoDomainModel(tempBasalInfusionInfo = tempBasal(speed = 3.5)))) + + assertThat(sut.tempBasalRate.value).isEqualTo(3.5) + } + + @Test + fun `observeInfusionInfo clears the temp basal speed when nothing is running`() { + sut.observeInfusionInfo() + infusionSubject.onNext(Optional.of(CarelevoInfusionInfoDomainModel(tempBasalInfusionInfo = tempBasal(speed = 3.5)))) + + infusionSubject.onNext(Optional.of(CarelevoInfusionInfoDomainModel())) + + assertThat(sut.tempBasalRate.value).isNull() + } + + @Test + fun `observeInfusionInfo re-latches the needle screen when the infusion record is gone`() { + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = true))) + sut.observeInfusionInfo() + + infusionSubject.onNext(Optional.empty()) + + assertThat(sut.isCheckScreen.value).isEqualTo(CarelevoScreenType.NEEDLE_INSERTION) + } + + @Test + fun `observeInfusionInfo ignores a missing infusion record when no patch is known`() { + sut.observeInfusionInfo() + + infusionSubject.onNext(Optional.empty()) + + assertThat(sut.isCheckScreen.value).isNull() + assertThat(sut.tempBasalRate.value).isNull() + } + + @Test + fun `observeInfusionInfo does not latch the needle screen while the needle is not yet inserted`() { + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = false))) + sut.observeInfusionInfo() + + infusionSubject.onNext(Optional.empty()) + + assertThat(sut.isCheckScreen.value).isNull() + } + + // ---- clearInfusionInfo / refreshPatchInfusionInfo ----------------------------------------- + + @Test + fun `clearInfusionInfo refreshes the patch status through the queue on success`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + + sut.clearInfusionInfo(CarelevoDeleteInfusionRequestModel(true, false, true)) + + verify(deleteInfusionInfoUseCase).execute(CarelevoDeleteInfusionRequestModel(true, false, true)) + verifyBlocking(commandQueue) { readStatus(any()) } + } + + @Test + fun `clearInfusionInfo does not refresh when the delete stream errors`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + whenever(deleteInfusionInfoUseCase.execute(any())).thenReturn(Single.error(RuntimeException("db down"))) + + sut.clearInfusionInfo(CarelevoDeleteInfusionRequestModel(true, true, true)) + + verifyBlocking(commandQueue, never()) { readStatus(any()) } + } + + @Test + fun `refreshPatchInfusionInfo reads the status through the queue when bluetooth is on`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + + sut.refreshPatchInfusionInfo() + + verifyBlocking(commandQueue) { readStatus(any()) } + } + + @Test + fun `refreshPatchInfusionInfo is a no-op while bluetooth is off`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(false) + + sut.refreshPatchInfusionInfo() + + verifyBlocking(commandQueue, never()) { readStatus(any()) } + } + + // ---- clearExpiredInfusions (driven by the init second-tick) -------------------------------- + + @Test + fun `the second tick deletes every infusion whose window has elapsed`() { + val past = DateTime.now().minusHours(2) + infusionSubject.onNext( + Optional.of( + CarelevoInfusionInfoDomainModel( + tempBasalInfusionInfo = tempBasal(durationMin = 30, createdAt = past), + immeBolusInfusionInfo = immeBolus(durationSeconds = 60, createdAt = past), + extendBolusInfusionInfo = extendBolus(durationMin = 30, createdAt = past) + ) + ) + ) + + // The tick runs on a real Dispatchers.Default clock (~1 s), so wait on the invocation. + // atLeastOnce: the ticker keeps firing, so a strict times(1) could race a second tick. + val captor = argumentCaptor() + verify(deleteInfusionInfoUseCase, timeout(3_000).atLeastOnce()).execute(captor.capture()) + assertThat(captor.firstValue).isEqualTo(CarelevoDeleteInfusionRequestModel(true, true, true)) + } + + @Test + fun `the second tick keeps infusions that are still running or have no end time`() { + infusionSubject.onNext( + Optional.of( + CarelevoInfusionInfoDomainModel( + // No duration → open-ended, never expires. + tempBasalInfusionInfo = tempBasal(durationMin = null), + // Ends in the future. + extendBolusInfusionInfo = extendBolus(durationMin = 120, createdAt = DateTime.now()) + ) + ) + ) + + verify(deleteInfusionInfoUseCase, after(1_500).never()).execute(any()) + } + + // ---- triggerEvent / generateEventType ------------------------------------------------------ + + @Test + fun `triggerEvent forwards a plain overview event untouched`() { + sut.triggerEvent(CarelevoOverviewEvent.ShowMessageBluetoothNotEnabled) + sut.triggerEvent(CarelevoOverviewEvent.ShowMessageCarelevoIsNotConnected) + sut.triggerEvent(CarelevoOverviewEvent.DiscardFailed) + sut.triggerEvent(CarelevoOverviewEvent.ResumePumpFailed) + sut.triggerEvent(CarelevoOverviewEvent.StopPumpFailed) + sut.triggerEvent(CarelevoOverviewEvent.StartConnectionFlow) + sut.triggerEvent(CarelevoOverviewEvent.ShowPumpDiscardDialog) + + assertThat(events).containsExactly( + CarelevoOverviewEvent.ShowMessageBluetoothNotEnabled, + CarelevoOverviewEvent.ShowMessageCarelevoIsNotConnected, + CarelevoOverviewEvent.DiscardFailed, + CarelevoOverviewEvent.ResumePumpFailed, + CarelevoOverviewEvent.StopPumpFailed, + CarelevoOverviewEvent.StartConnectionFlow, + CarelevoOverviewEvent.ShowPumpDiscardDialog + ).inOrder() + } + + @Test + fun `triggerEvent maps an unlisted overview event to NoAction`() { + // ShowPumpResumeDialog is not listed in generateEventType → the `else` arm. + sut.triggerEvent(CarelevoOverviewEvent.ShowPumpResumeDialog) + + assertThat(events).containsExactly(CarelevoOverviewEvent.NoAction) + } + + @Test + fun `triggerEvent ignores events belonging to another screen`() { + sut.triggerEvent(CarelevoConnectEvent.ExitFlow) + + assertThat(events).isEmpty() + } + + @Test + fun `stop-resume click reports the patch is not connected when there is no patch`() { + whenever(carelevoPatch.resolvePatchState()).thenReturn(PatchState.NotConnectedNotBooting) + + sut.triggerEvent(CarelevoOverviewEvent.ClickPumpStopResumeBtn) + + assertThat(events).containsExactly(CarelevoOverviewEvent.ShowMessageCarelevoIsNotConnected) + } + + @Test + fun `stop-resume click offers the resume dialog for a stopped pump`() { + whenever(carelevoPatch.resolvePatchState()).thenReturn(PatchState.ConnectedBooted) + patchInfoSubject.onNext(Optional.of(patchInfo(isStopped = true))) + + sut.triggerEvent(CarelevoOverviewEvent.ClickPumpStopResumeBtn) + + assertThat(events).containsExactly(CarelevoOverviewEvent.ShowPumpResumeDialog) + } + + @Test + fun `stop-resume click offers the stop-duration dialog for a running pump`() { + whenever(carelevoPatch.resolvePatchState()).thenReturn(PatchState.ConnectedBooted) + patchInfoSubject.onNext(Optional.of(patchInfo(isStopped = false))) + + sut.triggerEvent(CarelevoOverviewEvent.ClickPumpStopResumeBtn) + + assertThat(events).containsExactly(CarelevoOverviewEvent.ShowPumpStopDurationSelectDialog) + } + + @Test + fun `stop-resume click assumes running when the stop flag is unknown`() { + whenever(carelevoPatch.resolvePatchState()).thenReturn(PatchState.NotConnectedBooted) + patchInfoSubject.onNext(Optional.of(patchInfo(isStopped = null))) + + sut.triggerEvent(CarelevoOverviewEvent.ClickPumpStopResumeBtn) + + assertThat(events).containsExactly(CarelevoOverviewEvent.ShowPumpStopDurationSelectDialog) + } + + @Test + fun `stop-resume click assumes running when no patch report has arrived`() { + // patchInfo never emitted → `.value` is null → the `?: false` default. + whenever(carelevoPatch.resolvePatchState()).thenReturn(PatchState.ConnectedBooted) + + sut.triggerEvent(CarelevoOverviewEvent.ClickPumpStopResumeBtn) + + assertThat(events).containsExactly(CarelevoOverviewEvent.ShowPumpStopDurationSelectDialog) + } + + // ---- startDiscardProcess ------------------------------------------------------------------- + + @Test + fun `startDiscardProcess does nothing when no patch state is known`() { + sut.startDiscardProcess() + + verifyBlocking(commandQueue, never()) { customCommand(any()) } + verify(patchForceDiscardUseCase, never()).execute() + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startDiscardProcess does nothing when there is no active patch`() { + patchStateSubject.onNext(Optional.of(PatchState.NotConnectedNotBooting)) + + sut.startDiscardProcess() + + verifyBlocking(commandQueue, never()) { customCommand(any()) } + verify(patchForceDiscardUseCase, never()).execute() + } + + @Test + fun `startDiscardProcess routes an active patch through the queue`() { + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + stubCustomCommand(success = true) + + sut.startDiscardProcess() + + verifyBlocking(commandQueue) { customCommand(any()) } + // The queue's CmdDiscard owns unBond + releasePatch; the VM must not double-tear-down. + verify(carelevoPatch, never()).discardTeardown() + verify(patchForceDiscardUseCase, never()).execute() + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + assertThat(events).isEmpty() + } + + @Test + fun `startDiscardProcess falls back to force-discard when the queue cannot reach the patch`() { + patchStateSubject.onNext(Optional.of(PatchState.NotConnectedBooted)) + stubCustomCommand(success = false) + whenever(patchForceDiscardUseCase.execute()) + .thenReturn(Single.just(ResponseResult.Success(ResultSuccess as CarelevoUseCaseResponse))) + + sut.startDiscardProcess() + + verify(patchForceDiscardUseCase).execute() + verify(carelevoPatch).discardTeardown() + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + assertThat(events).isEmpty() + } + + @Test + fun `startDiscardProcess reports failure when force-discard is rejected`() { + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + stubCustomCommand(success = false) + whenever(patchForceDiscardUseCase.execute()).thenReturn(Single.just(ResponseResult.Failure("rejected"))) + + sut.startDiscardProcess() + + assertThat(events).containsExactly(CarelevoOverviewEvent.DiscardFailed) + verify(carelevoPatch, never()).discardTeardown() + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startDiscardProcess reports failure when force-discard returns an error result`() { + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + stubCustomCommand(success = false) + whenever(patchForceDiscardUseCase.execute()).thenReturn(Single.just(ResponseResult.Error(RuntimeException("db")))) + + sut.startDiscardProcess() + + assertThat(events).containsExactly(CarelevoOverviewEvent.DiscardFailed) + verify(carelevoPatch, never()).discardTeardown() + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startDiscardProcess reports failure when the force-discard stream throws`() { + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + stubCustomCommand(success = false) + whenever(patchForceDiscardUseCase.execute()).thenReturn(Single.error(RuntimeException("boom"))) + + sut.startDiscardProcess() + + assertThat(events).containsExactly(CarelevoOverviewEvent.DiscardFailed) + verify(carelevoPatch, never()).discardTeardown() + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + // ---- startPumpStopProcess ------------------------------------------------------------------ + + @Test + fun `startPumpStopProcess reports bluetooth off and never touches the queue`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(false) + + sut.startPumpStopProcess(30) + + assertThat(events).containsExactly(CarelevoOverviewEvent.ShowMessageBluetoothNotEnabled) + verifyBlocking(commandQueue, never()) { customCommand(any()) } + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startPumpStopProcess stops an idle pump and syncs the suspension`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + patchInfoSubject.onNext(Optional.of(patchInfo(manufactureNumber = "SN-0001"))) + stubCustomCommand(success = true) + + sut.startPumpStopProcess(45) + + val captor = argumentCaptor() + verifyBlocking(commandQueue) { customCommand(captor.capture()) } + assertThat((captor.firstValue as CmdPumpStop).durationMin).isEqualTo(45) + // Nothing was running → no pre-cancel round-trips. + verifyBlocking(commandQueue, never()) { cancelTempBasal(any(), any()) } + verifyBlocking(commandQueue, never()) { cancelExtended() } + verifyBlocking(pumpSync) { + syncTemporaryBasalWithPumpId(any(), any(), any(), any(), anyOrNull(), any(), any(), any()) + } + verifyBlocking(pumpSync) { syncStopExtendedBolusWithPumpId(any(), any(), any(), any()) } + verify(deleteInfusionInfoUseCase).execute(CarelevoDeleteInfusionRequestModel(false, false, false)) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + assertThat(events).isEmpty() + } + + @Test + fun `startPumpStopProcess cancels a running temp basal before stopping`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + infusionSubject.onNext(Optional.of(CarelevoInfusionInfoDomainModel(tempBasalInfusionInfo = tempBasal()))) + stubCancelTempBasal(success = true) + stubCustomCommand(success = true) + + sut.startPumpStopProcess(60) + + verifyBlocking(commandQueue) { cancelTempBasal(any(), any()) } + verifyBlocking(commandQueue, never()) { cancelExtended() } + verifyBlocking(commandQueue) { customCommand(any()) } + // The cleanup must mirror what was actually running. + verify(deleteInfusionInfoUseCase).execute(CarelevoDeleteInfusionRequestModel(true, false, false)) + assertThat(events).isEmpty() + } + + @Test + fun `startPumpStopProcess cancels a running extended bolus before stopping`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + infusionSubject.onNext(Optional.of(CarelevoInfusionInfoDomainModel(extendBolusInfusionInfo = extendBolus()))) + stubCancelExtended(success = true) + stubCustomCommand(success = true) + + sut.startPumpStopProcess(60) + + verifyBlocking(commandQueue) { cancelExtended() } + verifyBlocking(commandQueue, never()) { cancelTempBasal(any(), any()) } + verifyBlocking(commandQueue) { customCommand(any()) } + verify(deleteInfusionInfoUseCase).execute(CarelevoDeleteInfusionRequestModel(false, false, true)) + assertThat(events).isEmpty() + } + + @Test + fun `startPumpStopProcess cancels both running infusions before stopping`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + infusionSubject.onNext( + Optional.of( + CarelevoInfusionInfoDomainModel( + tempBasalInfusionInfo = tempBasal(), + extendBolusInfusionInfo = extendBolus() + ) + ) + ) + stubCancelTempBasal(success = true) + stubCancelExtended(success = true) + stubCustomCommand(success = true) + + sut.startPumpStopProcess(15) + + verifyBlocking(commandQueue) { cancelTempBasal(any(), any()) } + verifyBlocking(commandQueue) { cancelExtended() } + verify(deleteInfusionInfoUseCase).execute(CarelevoDeleteInfusionRequestModel(true, false, true)) + assertThat(events).isEmpty() + } + + @Test + fun `startPumpStopProcess aborts when the temp basal cannot be cancelled`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + infusionSubject.onNext(Optional.of(CarelevoInfusionInfoDomainModel(tempBasalInfusionInfo = tempBasal()))) + stubCancelTempBasal(success = false) + + sut.startPumpStopProcess(30) + + assertThat(events).containsExactly(CarelevoOverviewEvent.StopPumpFailed) + verifyBlocking(commandQueue, never()) { customCommand(any()) } + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startPumpStopProcess aborts when the extended bolus cannot be cancelled`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + infusionSubject.onNext(Optional.of(CarelevoInfusionInfoDomainModel(extendBolusInfusionInfo = extendBolus()))) + stubCancelExtended(success = false) + + sut.startPumpStopProcess(30) + + assertThat(events).containsExactly(CarelevoOverviewEvent.StopPumpFailed) + verifyBlocking(commandQueue, never()) { customCommand(any()) } + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startPumpStopProcess reports failure when the queued stop frame fails`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + stubCustomCommand(success = false) + + sut.startPumpStopProcess(30) + + assertThat(events).containsExactly(CarelevoOverviewEvent.StopPumpFailed) + // A failed stop must not fabricate a suspension in the DB. + verifyBlocking(pumpSync, never()) { + syncTemporaryBasalWithPumpId(any(), any(), any(), any(), anyOrNull(), any(), any(), any()) + } + verify(deleteInfusionInfoUseCase, never()).execute(any()) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startPumpStopProcess syncs an empty serial when no patch report has arrived`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + stubCustomCommand(success = true) + + sut.startPumpStopProcess(30) + + val serial = argumentCaptor() + verifyBlocking(pumpSync) { syncStopExtendedBolusWithPumpId(any(), any(), any(), serial.capture()) } + assertThat(serial.firstValue).isEqualTo("") + } + + // ---- startPumpResume ----------------------------------------------------------------------- + + @Test + fun `startPumpResume reports bluetooth off and never touches the queue`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(false) + + sut.startPumpResume() + + assertThat(events).containsExactly(CarelevoOverviewEvent.ShowMessageBluetoothNotEnabled) + verifyBlocking(commandQueue, never()) { customCommand(any()) } + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startPumpResume ends the suspension TBR when the queued frame succeeds`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + patchInfoSubject.onNext(Optional.of(patchInfo(manufactureNumber = "SN-0001", isStopped = true))) + stubCustomCommand(success = true) + + sut.startPumpResume() + + verifyBlocking(commandQueue) { customCommand(any()) } + val serial = argumentCaptor() + // `ignorePumpIds` has a default value → the mock records all 5 args. + verifyBlocking(pumpSync) { syncStopTemporaryBasalWithPumpId(any(), any(), any(), serial.capture(), any()) } + assertThat(serial.firstValue).isEqualTo("SN-0001") + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + assertThat(events).isEmpty() + } + + @Test + fun `startPumpResume reports failure and leaves the TBR alone when the queued frame fails`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + stubCustomCommand(success = false) + + sut.startPumpResume() + + assertThat(events).containsExactly(CarelevoOverviewEvent.ResumePumpFailed) + verifyBlocking(pumpSync, never()) { syncStopTemporaryBasalWithPumpId(any(), any(), any(), any(), any()) } + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + // ---- overviewUiState assembly --------------------------------------------------------------- + + @Test + fun `overview warns and offers connect while no patch is activated`() { + val state = sut.overviewUiState.value + + assertThat(state.statusBanner).isNotNull() + assertThat(state.statusBanner?.text).isEqualTo(s(R.string.carelevo_state_none_value)) + assertThat(state.statusBanner?.level).isEqualTo(StatusLevel.WARNING) + assertThat(state.queueStatus).isNull() + // Only the bluetooth-state row; no patch data to show. + assertThat(infoRows()).hasSize(1) + assertThat(infoRows()[0].label).isEqualTo(s(R.string.carelevo_bluetooth_state_key)) + assertThat(infoRows()[0].value).isEqualTo(s(R.string.carelevo_state_none_value)) + assertThat(state.primaryActions).hasSize(1) + assertThat(state.primaryActions[0].label).isEqualTo(s(R.string.carelevo_overview_connect_btn_label)) + assertThat(state.primaryActions[0].category).isEqualTo(ActionCategory.PRIMARY) + assertThat(state.managementActions).isEmpty() + } + + @Test + fun `overview shows the full status for a connected patch`() { + val profile = mock() + whenever(profile.getBasal()).thenReturn(1.75) + profileSubject.onNext(Optional.of(profile)) + sut.observePatchInfo() + sut.observePatchState() + sut.observeInfusionInfo() + patchInfoSubject.onNext(Optional.of(patchInfo())) + infusionSubject.onNext(Optional.of(CarelevoInfusionInfoDomainModel(tempBasalInfusionInfo = tempBasal(speed = 2.5)))) + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + + connectedFlow.value = true + val state = sut.overviewUiState.value + val rows = infoRows() + + // An activated patch surfaces the shared communication status instead of a warning banner. + assertThat(state.statusBanner).isNull() + assertThat(rows).hasSize(11) + assertThat(rows[0].value).isEqualTo(s(R.string.carelevo_state_connected_value)) + assertThat(rows[1].label).isEqualTo(s(CoreUiR.string.last_connection_label)) + assertThat(rows[2].label).isEqualTo(s(R.string.carelevo_serial_number_key)) + assertThat(rows[2].value).isEqualTo("SN-0001") + assertThat(rows[3].value).isEqualTo("T168") + assertThat(rows[4].value).isEqualTo(uiFormat(local(nowMillis - 2 * dayMillis))) + assertThat(rows[5].value).isEqualTo(uiFormat(local(nowMillis - 2 * dayMillis).plusDays(7))) + assertThat(rows[7].value).isEqualTo(s(R.string.common_label_unit_value_dose_per_speed_with_space, 1.75)) + assertThat(rows[8].value).isEqualTo(s(R.string.common_label_unit_value_dose_per_speed_with_space, 2.5)) + assertThat(rows[10].value).isEqualTo( + s(R.string.common_label_unit_value_dose_with_space, String.format(Locale.US, "%.2f", 3.58)) + ) + assertThat(state.primaryActions).isEmpty() + assertThat(state.managementActions).hasSize(2) + assertThat(state.managementActions[0].label).isEqualTo(s(R.string.carelevo_overview_pump_discard_btn_label)) + assertThat(state.managementActions[0].category).isEqualTo(ActionCategory.MANAGEMENT) + assertThat(state.managementActions[1].label).isEqualTo(s(CoreUiR.string.pump_suspend)) + } + + @Test + fun `overview keeps the last known status for an idle-disconnected patch`() { + sut.observePatchInfo() + sut.observePatchState() + patchInfoSubject.onNext(Optional.of(patchInfo())) + patchStateSubject.onNext(Optional.of(PatchState.NotConnectedBooted)) + + val state = sut.overviewUiState.value + + // Idle-disconnect is normal (the queue reconnects on demand) → no warning banner. + assertThat(state.statusBanner).isNull() + assertThat(infoRows()).hasSize(11) + assertThat(infoRows()[0].value).isEqualTo(s(R.string.carelevo_state_disconnected_value)) + assertThat(state.primaryActions).isEmpty() + assertThat(state.managementActions).hasSize(2) + } + + @Test + fun `overview offers resume instead of suspend for a stopped pump`() { + sut.observePatchInfo() + sut.observePatchState() + patchInfoSubject.onNext(Optional.of(patchInfo(isStopped = true))) + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + + assertThat(sut.overviewUiState.value.managementActions[1].label).isEqualTo(s(CoreUiR.string.pump_resume)) + } + + @Test + fun `a stopped pump shows the suspended banner as the top status`() { + sut.observePatchInfo() + sut.observePatchState() + patchInfoSubject.onNext(Optional.of(patchInfo(isStopped = true))) + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + + val banner = sut.overviewUiState.value.statusBanner + assertThat(banner?.text).isEqualTo(s(CoreUiR.string.pumpsuspended)) + assertThat(banner?.level).isEqualTo(StatusLevel.WARNING) + } + + @Test + fun `the bluetooth row goes live and the last-connection row shows time since the last session`() { + whenever(dateUtil.minAgo(any(), any())).thenReturn("5m ago") + sut.observePatchInfo() + sut.observePatchState() + patchInfoSubject.onNext(Optional.of(patchInfo())) + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + lastConnectedFlow.value = nowMillis - 5 * 60_000L + + // Idle: Bluetooth row reads Disconnected even though the patch is activated. + assertThat(infoRows()[0].value).isEqualTo(s(R.string.carelevo_state_disconnected_value)) + // Row 1 is the "last connection: N ago" reachability line (shared DateUtil.minAgo). + val lastConnection = infoRows()[1] + assertThat(lastConnection.label).isEqualTo(s(CoreUiR.string.last_connection_label)) + assertThat(lastConnection.value).isEqualTo("5m ago") + + // A live session flips it to Connected. + connectedFlow.value = true + assertThat(infoRows()[0].value).isEqualTo(s(R.string.carelevo_state_connected_value)) + } + + @Test + fun `overview dashes out unknown patch values`() { + sut.observePatchInfo() + sut.observePatchState() + patchInfoSubject.onNext( + Optional.of( + patchInfo( + manufactureNumber = null, firmwareVersion = null, insulinRemain = null, + bootDateTimeUtcMillis = null, bootDateTime = null + ) + ) + ) + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + + val rows = infoRows() + assertThat(rows[2].value).isEqualTo("-") + assertThat(rows[3].value).isEqualTo("-") + assertThat(rows[4].value).isEqualTo("-") + assertThat(rows[5].value).isEqualTo("-") + // runningRemainMinutes == 0 → nothing to count down. + assertThat(rows[6].value).isEqualTo("-") + assertThat(rows[9].value).isEqualTo("-") + } + + @Test + fun `overview formats a multi-day remaining time with the day-hour-minute template`() { + sut.observePatchInfo() + sut.observePatchState() + patchInfoSubject.onNext(Optional.of(patchInfo(bootDateTimeUtcMillis = nowMillis - 2 * dayMillis))) + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + + val total = sut.runningRemainMinutes.value!! + val expected = s(R.string.common_unit_value_day_hour_min, total / 1440, (total % 1440) / 60, total % 60) + assertThat(infoRows()[6].value).isEqualTo(expected) + assertThat(total / 1440).isGreaterThan(0) + } + + @Test + fun `overview formats a sub-day remaining time as hours and minutes`() { + // Booted 7 days ago plus 100 minutes → ~100 min left, i.e. the days == 0 branch. + val bootMillis = nowMillis - 7 * dayMillis + 100 * 60_000L + sut.observePatchInfo() + sut.observePatchState() + patchInfoSubject.onNext(Optional.of(patchInfo(bootDateTimeUtcMillis = bootMillis))) + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + + // Derived, not hardcoded: the VM measures the countdown in local-calendar minutes. + val expected = ChronoUnit.MINUTES.between(local(nowMillis), local(bootMillis).plusDays(7)).toInt() + val total = sut.runningRemainMinutes.value!! + assertThat(total).isEqualTo(expected) + assertThat(total).isGreaterThan(0) + assertThat(total).isLessThan(1440) + assertThat(infoRows()[6].value) + .isEqualTo(String.format(Locale.getDefault(), "%02d:%02d", total / 60, total % 60)) + } + + @Test + fun `overview temp basal row falls back to zero when nothing is running`() { + sut.observePatchInfo() + sut.observePatchState() + sut.observeInfusionInfo() + patchInfoSubject.onNext(Optional.of(patchInfo())) + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + infusionSubject.onNext(Optional.of(CarelevoInfusionInfoDomainModel())) + + assertThat(infoRows()[8].value).isEqualTo(s(R.string.common_label_unit_value_dose_per_speed_with_space, 0.0)) + } + + @Test + fun `overview drops back to the warning banner once the patch is discarded`() { + sut.observePatchState() + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + assertThat(sut.overviewUiState.value.statusBanner).isNull() + + patchStateSubject.onNext(Optional.of(PatchState.NotConnectedNotBooting)) + + val state = sut.overviewUiState.value + assertThat(state.statusBanner?.level).isEqualTo(StatusLevel.WARNING) + assertThat(infoRows()).hasSize(1) + assertThat(state.managementActions).isEmpty() + assertThat(state.primaryActions).hasSize(1) + } + + // ---- overview action wiring ----------------------------------------------------------------- + + @Test + fun `the connect action asks the screen to start the connection flow`() { + sut.overviewUiState.value.primaryActions[0].onClick() + + assertThat(events).containsExactly(CarelevoOverviewEvent.StartConnectionFlow) + } + + @Test + fun `the discard action asks the screen for confirmation`() { + sut.observePatchState() + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + + sut.overviewUiState.value.managementActions[0].onClick() + + assertThat(events).containsExactly(CarelevoOverviewEvent.ShowPumpDiscardDialog) + } + + @Test + fun `the suspend action resolves to the stop-duration dialog`() { + sut.observePatchInfo() + sut.observePatchState() + patchInfoSubject.onNext(Optional.of(patchInfo(isStopped = false))) + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + whenever(carelevoPatch.resolvePatchState()).thenReturn(PatchState.ConnectedBooted) + + sut.overviewUiState.value.managementActions[1].onClick() + + assertThat(events).containsExactly(CarelevoOverviewEvent.ShowPumpStopDurationSelectDialog) + } + + @Test + fun `the resume action resolves to the resume dialog`() { + sut.observePatchInfo() + sut.observePatchState() + patchInfoSubject.onNext(Optional.of(patchInfo(isStopped = true))) + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + whenever(carelevoPatch.resolvePatchState()).thenReturn(PatchState.ConnectedBooted) + + sut.overviewUiState.value.managementActions[1].onClick() + + assertThat(events).containsExactly(CarelevoOverviewEvent.ShowPumpResumeDialog) + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchConnectViewModelTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchConnectViewModelTest.kt new file mode 100644 index 000000000000..be3481755b48 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchConnectViewModelTest.kt @@ -0,0 +1,656 @@ +package app.aaps.pump.carelevo.presentation.viewmodel + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.pump.ble.BleAdapter +import app.aaps.core.interfaces.pump.ble.BleScanner +import app.aaps.core.interfaces.pump.ble.ScannedDevice +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.interfaces.sharedPreferences.SP +import app.aaps.pump.carelevo.ble.CarelevoBleSession +import app.aaps.pump.carelevo.ble.CarelevoBleTransport +import app.aaps.pump.carelevo.command.CmdDiscard +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.common.keys.CarelevoBooleanPreferenceKey +import app.aaps.pump.carelevo.common.keys.CarelevoIntPreferenceKey +import app.aaps.pump.carelevo.common.model.Event +import app.aaps.pump.carelevo.common.model.PatchState +import app.aaps.pump.carelevo.common.model.UiState +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.result.ResultSuccess +import app.aaps.pump.carelevo.domain.model.userSetting.CarelevoUserSettingInfoDomainModel +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoConnectNewPatchUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchForceDiscardUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.model.CarelevoConnectNewPatchRequestModel +import app.aaps.pump.carelevo.presentation.model.CarelevoConnectPrepareEvent +import app.aaps.pump.carelevo.presentation.model.CarelevoOverviewEvent +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.plugins.RxJavaPlugins +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.BehaviorSubject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.Optional +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Unit tests for [CarelevoPatchConnectViewModel] — the activation wizard's scan/pair/discard step. + * + * **Plain JVM (JUnit5), not Robolectric.** The ViewModel touches no Android framework: it only drives + * coroutines, RxJava and flows over mockable collaborators. `viewModelScope` needs a Main dispatcher, + * which [Dispatchers.setMain] supplies. Every collaborator is mocked — the final Kotlin classes + * ([CarelevoPatch], [CarelevoBleSession], the two use cases) mock fine under Mockito 5's inline + * mock-maker, exactly as `CarelevoPumpPluginTestBase` in this module already does. + * + * **Dispatcher strategy** (the VM mixes two): + * - `triggerEvent`, and the queue branch of `startPatchDiscardProcess`, use a plain + * `viewModelScope.launch { }` → they inherit Main. Main is an [UnconfinedTestDispatcher], so those + * bodies run **eagerly and synchronously** inside the call and can be asserted straight after it. + * - `startScan` / `startConnect` launch with an explicit `Dispatchers.IO`, which overrides the test + * Main dispatcher and cannot be virtualised (the VM injects no dispatcher). Those run on the real + * IO pool, so their tests [awaitEvent] on the terminal event instead of advancing virtual time. + * + * `startPatchForceDiscard`'s Rx chain is pinned to [Schedulers.trampoline] via the mocked + * [AapsSchedulers], so it too completes synchronously on the test thread. + * + * One-shot events are captured through a collector subscribed in [setUp] — [CarelevoPatchConnectViewModel.event] + * is a consume-once `EventFlow`, so exactly one collector must own it. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoPatchConnectViewModelTest { + + @Mock lateinit var aapsLogger: AAPSLogger + @Mock lateinit var aapsSchedulers: AapsSchedulers + @Mock lateinit var carelevoPatch: CarelevoPatch + @Mock lateinit var commandQueue: CommandQueue + @Mock lateinit var sp: SP + @Mock lateinit var bleSession: CarelevoBleSession + @Mock lateinit var transport: CarelevoBleTransport + @Mock lateinit var scanner: BleScanner + @Mock lateinit var adapter: BleAdapter + @Mock lateinit var connectNewPatchUseCase: CarelevoConnectNewPatchUseCase + @Mock lateinit var patchForceDiscardUseCase: CarelevoPatchForceDiscardUseCase + + private lateinit var sut: CarelevoPatchConnectViewModel + + /** Thread-safe: events emitted from the IO-launched paths land on an IO thread, asserts read here. */ + private val events = CopyOnWriteArrayList() + private lateinit var collectorScope: CoroutineScope + + private val device = ScannedDevice(name = "CareLevo-1234", address = "94:b2:16:1d:2f:6d", rssi = -40) + + private val pairingResult = CarelevoBleSession.PairingResult( + address = "94:b2:16:1d:2f:6d", + serialNumber = "CARELEVO-TEST-001", + firmwareVersion = "T168", + modelName = "6776514848" + ) + + private val userSettings = CarelevoUserSettingInfoDomainModel( + lowInsulinNoticeAmount = 30, + maxBasalSpeed = 15.0, + maxBolusDose = 25.0 + ) + + @BeforeEach + fun setUp() { + // viewModelScope resolves Dispatchers.Main.immediate at first use; Unconfined makes every + // Main-launched body (triggerEvent, the discard queue branch) run in-line. + Dispatchers.setMain(UnconfinedTestDispatcher()) + // startPatchForceDiscard's `.subscribe { }` has no onError arm, so an errored stream reaches + // the global Rx handler as OnErrorNotImplementedException *after* doOnError already ran. + // Swallow it so that (intended) branch does not trip the default uncaught-exception handler. + RxJavaPlugins.setErrorHandler { } + + whenever(aapsSchedulers.io).thenReturn(Schedulers.trampoline()) + whenever(aapsSchedulers.main).thenReturn(Schedulers.trampoline()) + whenever(transport.scanner).thenReturn(scanner) + whenever(transport.adapter).thenReturn(adapter) + + sut = CarelevoPatchConnectViewModel( + aapsLogger = aapsLogger, + aapsSchedulers = aapsSchedulers, + carelevoPatch = carelevoPatch, + commandQueue = commandQueue, + sp = sp, + bleSession = bleSession, + transport = transport, + connectNewPatchUseCase = connectNewPatchUseCase, + patchForceDiscardUseCase = patchForceDiscardUseCase + ) + + // Subscribe before any action: the EventFlow replays 1 but each slot is consumed exactly once. + collectorScope = CoroutineScope(Dispatchers.Unconfined) + collectorScope.launch { sut.event.collect { events.add(it) } } + } + + @AfterEach + fun tearDown() { + collectorScope.cancel() + RxJavaPlugins.reset() + Dispatchers.resetMain() + } + + // ---- helpers ------------------------------------------------------------------------------ + + /** Block until an IO-launched path emits its terminal event (see class KDoc: real IO pool). */ + private fun awaitEvent(timeoutMs: Long = 3_000L) { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline && events.isEmpty()) Thread.sleep(5) + assertThat(events).isNotEmpty() + } + + private fun patchStateSubject(state: PatchState?): BehaviorSubject> = + if (state == null) BehaviorSubject.create() else BehaviorSubject.createDefault(Optional.of(state)) + + private fun userSettingSubject(model: CarelevoUserSettingInfoDomainModel?): BehaviorSubject> = + if (model == null) BehaviorSubject.create() else BehaviorSubject.createDefault(Optional.of(model)) + + /** `_selectedDevice` is private and only ever written by a successful scan; seed it directly. */ + private fun setSelectedDevice(value: ScannedDevice?) { + val field = CarelevoPatchConnectViewModel::class.java.getDeclaredField("_selectedDevice") + field.isAccessible = true + field.set(sut, value) + } + + /** Simulate "a scan is already in flight" without leaving a real 10 s scan coroutine running. */ + private fun setScanWorking(value: Boolean) { + val field = CarelevoPatchConnectViewModel::class.java.getDeclaredField("_isScanWorking") + field.isAccessible = true + field.setBoolean(sut, value) + } + + /** The VM's private Rx bag — used to prove `onCleared` actually releases in-flight work. */ + private fun compositeDisposable(): CompositeDisposable { + val field = CarelevoPatchConnectViewModel::class.java.getDeclaredField("compositeDisposable") + field.isAccessible = true + return field.get(sut) as CompositeDisposable + } + + /** `onCleared` is `protected` (inherited from [androidx.lifecycle.ViewModel]); the framework calls it. */ + private fun invokeOnCleared() { + val method = CarelevoPatchConnectViewModel::class.java.getDeclaredMethod("onCleared") + method.isAccessible = true + method.invoke(sut) + } + + /** Queue answer for `commandQueue.customCommand`; built before the stub that returns it. */ + private fun stubQueueResult(success: Boolean) { + val result = mock() + whenever(result.success).thenReturn(success) + whenever { commandQueue.customCommand(any()) }.thenReturn(result) + } + + private fun stubForceDiscardSuccess() { + whenever(patchForceDiscardUseCase.execute()).thenReturn(Single.just(ResponseResult.Success(ResultSuccess))) + } + + // ---- initial state ------------------------------------------------------------------------ + + @Test + fun `uiState starts Idle`() { + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `isScanWorking starts false`() { + assertThat(sut.isScanWorking).isFalse() + } + + // ---- startScan guards --------------------------------------------------------------------- + + @Test + fun `startScan reports bluetooth off and never touches the scanner`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(false) + + sut.startScan() + + assertThat(events).contains(CarelevoConnectPrepareEvent.ShowMessageBluetoothNotEnabled) + verify(scanner, never()).startScan() + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + assertThat(sut.isScanWorking).isFalse() + } + + @Test + fun `startScan reports a scan already in progress and does not start a second one`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + setScanWorking(true) + + sut.startScan() + + assertThat(events).contains(CarelevoConnectPrepareEvent.ShowMessageScanIsWorking) + verify(scanner, never()).startScan() + // Guard returns before setUiState(Loading). + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + // ---- startScan discovery ------------------------------------------------------------------ + + @Test + fun `startScan picks an advertised patch above the RSSI floor and offers the connect dialog`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + // Discovery collects for the whole DISCOVERY_COLLECTION_MS window (no early stop) then selects the + // strongest-RSSI device seen. replay=1 buffers the advertisement so the collector cannot miss it. + whenever(scanner.scannedDevices).thenReturn(MutableSharedFlow(replay = 1).apply { tryEmit(device) }) + + sut.startScan() + // The window runs its full duration on the real IO pool, so wait past DISCOVERY_COLLECTION_MS. + awaitEvent(timeoutMs = 15_000L) + + assertThat(events).contains(CarelevoConnectPrepareEvent.ShowConnectDialog) + // scanAddress = null puts the scanner in service-UUID discovery mode (no MAC filter). + verify(transport).scanAddress = null + verify(scanner).startScan() + // The finally arm stops the scanner and releases the latch once the window closes. + verify(scanner).stopScan() + assertThat(sut.isScanWorking).isFalse() + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startScan reports failure when the only advertisement is below the RSSI floor`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + // Too weak to be a nearby patch (below MIN_SCAN_RSSI = -45) → filtered out, so the window expires + // with no selectable device and the scan reports failure. + val weak = ScannedDevice(name = "CareLevo-far", address = "94:b2:16:1d:2f:99", rssi = -80) + whenever(scanner.scannedDevices).thenReturn(MutableSharedFlow(replay = 1).apply { tryEmit(weak) }) + + sut.startScan() + awaitEvent(timeoutMs = 15_000L) + + assertThat(events).contains(CarelevoConnectPrepareEvent.ShowMessageScanFailed) + assertThat(events).doesNotContain(CarelevoConnectPrepareEvent.ShowConnectDialog) + verify(scanner).stopScan() + assertThat(sut.isScanWorking).isFalse() + } + + @Test + fun `startScan reports failure when the scan window expires with no advertisement`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + // Never emits → withTimeoutOrNull returns null only after the real 10 s SCAN_TIMEOUT_MS. + // This is the one deliberately slow test: the VM hardcodes Dispatchers.IO, so the window + // cannot be advanced with virtual time. + whenever(scanner.scannedDevices).thenReturn(MutableSharedFlow()) + + sut.startScan() + awaitEvent(timeoutMs = 15_000L) + + assertThat(events).contains(CarelevoConnectPrepareEvent.ShowMessageScanFailed) + verify(scanner).startScan() + // The finally arm still stops the scanner and releases the latch on the timeout path. + verify(scanner).stopScan() + assertThat(sut.isScanWorking).isFalse() + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + // ---- startConnect guards ------------------------------------------------------------------ + + @Test + fun `startConnect reports bluetooth off before pairing`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(false) + setSelectedDevice(device) + + sut.startConnect(300) + + assertThat(events).contains(CarelevoConnectPrepareEvent.ShowMessageBluetoothNotEnabled) + verifyBlocking(bleSession, never()) { runPairing(any(), any()) } + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startConnect reports an empty selection when no patch was scanned`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + setSelectedDevice(null) + + sut.startConnect(300) + + assertThat(events).contains(CarelevoConnectPrepareEvent.ShowMessageSelectedDeviceIseEmpty) + verifyBlocking(bleSession, never()) { runPairing(any(), any()) } + } + + @Test + fun `startConnect reports missing user settings before pairing`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + setSelectedDevice(device) + whenever(carelevoPatch.userSettingInfo).thenReturn(userSettingSubject(null)) + + sut.startConnect(300) + + assertThat(events).contains(CarelevoConnectPrepareEvent.ShowMessageNotSetUserSettingInfo) + verifyBlocking(bleSession, never()) { runPairing(any(), any()) } + verify(adapter, never()).removeBond(any()) + } + + // ---- startConnect pairing ----------------------------------------------------------------- + + @Test + fun `startConnect clears the stale bond pairs the patch and persists it`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + setSelectedDevice(device) + whenever(carelevoPatch.userSettingInfo).thenReturn(userSettingSubject(userSettings)) + whenever { bleSession.runPairing(any(), any()) }.thenReturn(pairingResult) + whenever(connectNewPatchUseCase.persistNewPatch(any(), any(), any(), any(), any())).thenReturn(true) + + sut.startConnect(300) + awaitEvent() + + assertThat(events).contains(CarelevoConnectPrepareEvent.ConnectComplete) + // Stale bond must be cleared before the pairing session dials. + verify(adapter).removeBond(device.address) + verifyBlocking(bleSession) { runPairing(any(), any()) } + verify(connectNewPatchUseCase).persistNewPatch(any(), any(), any(), any(), any()) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startConnect builds the pairing spec from the wizard input the prefs and the user settings`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + setSelectedDevice(device) + whenever(carelevoPatch.userSettingInfo).thenReturn(userSettingSubject(userSettings)) + whenever(sp.getInt(CarelevoIntPreferenceKey.CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS.key, 116)).thenReturn(72) + whenever(sp.getBoolean(CarelevoBooleanPreferenceKey.CARELEVO_BUZZER_REMINDER.key, false)).thenReturn(true) + whenever { bleSession.runPairing(any(), any()) }.thenReturn(pairingResult) + whenever(connectNewPatchUseCase.persistNewPatch(any(), any(), any(), any(), any())).thenReturn(true) + + sut.startConnect(300) + awaitEvent() + + val addressCaptor = argumentCaptor() + val specCaptor = argumentCaptor() + verifyBlocking(bleSession) { runPairing(addressCaptor.capture(), specCaptor.capture()) } + + // The session dials the MAC the scan reported ... + assertThat(addressCaptor.firstValue).isEqualTo(device.address) + // ... and the spec carries the wizard's fill volume plus the safety limits the patch itself + // enforces (maxBasalSpeed/maxBolusDose) — these are written to the patch once, at activation, + // so a wrong value here is not correctable later without a re-pair. + assertThat(specCaptor.firstValue).isEqualTo( + CarelevoBleSession.PairingSpec( + volume = 300, + remains = 30, + expiry = 72, + maxBasalSpeed = 15.0, + maxBolusDose = 25.0, + buzzUse = true + ) + ) + } + + @Test + fun `startConnect persists the MAC the pairing session read from the patch not the scanned address`() { + // The 0x3B MAC read is authoritative: the advertised address and the patch's own MAC need not + // match, and every later session dials the persisted one. + val sessionReported = pairingResult.copy(address = "aa:bb:cc:dd:ee:ff") + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + setSelectedDevice(device) + whenever(carelevoPatch.userSettingInfo).thenReturn(userSettingSubject(userSettings)) + whenever(sp.getInt(CarelevoIntPreferenceKey.CARELEVO_PATCH_EXPIRATION_REMINDER_HOURS.key, 116)).thenReturn(72) + whenever { bleSession.runPairing(any(), any()) }.thenReturn(sessionReported) + whenever(connectNewPatchUseCase.persistNewPatch(any(), any(), any(), any(), any())).thenReturn(true) + + sut.startConnect(250) + awaitEvent() + + val addressCaptor = argumentCaptor() + val requestCaptor = argumentCaptor() + verify(connectNewPatchUseCase).persistNewPatch( + addressCaptor.capture(), + eq(sessionReported.serialNumber), + eq(sessionReported.firmwareVersion), + eq(sessionReported.modelName), + requestCaptor.capture() + ) + assertThat(addressCaptor.firstValue).isEqualTo("aa:bb:cc:dd:ee:ff") + assertThat(addressCaptor.firstValue).isNotEqualTo(device.address) + // The same request that drove the pairing writes is the one persisted. + assertThat(requestCaptor.firstValue.volume).isEqualTo(250) + assertThat(requestCaptor.firstValue.expiry).isEqualTo(72) + assertThat(requestCaptor.firstValue.maxBasalSpeed).isEqualTo(15.0) + assertThat(requestCaptor.firstValue.maxVolume).isEqualTo(25.0) + } + + @Test + fun `startConnect lets a cancellation cancel the coroutine instead of reporting a pairing failure`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + setSelectedDevice(device) + whenever(carelevoPatch.userSettingInfo).thenReturn(userSettingSubject(userSettings)) + val pairingCalled = CountDownLatch(1) + whenever { bleSession.runPairing(any(), any()) }.thenAnswer { + pairingCalled.countDown() + throw CancellationException("wizard step left") + } + + sut.startConnect(300) + + // runCatching swallows CancellationException too, so the onFailure arm must rethrow it: leaving + // it to fall through to ConnectFailed would pop a spurious "pairing failed" dialog when the user + // simply navigated away mid-pair. + assertThat(pairingCalled.await(3, TimeUnit.SECONDS)).isTrue() + Thread.sleep(200) // let the fold arm run; it must stay silent + assertThat(events).isEmpty() + verify(connectNewPatchUseCase, never()).persistNewPatch(any(), any(), any(), any(), any()) + } + + @Test + fun `startConnect reports failure when persisting the new patch fails`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + setSelectedDevice(device) + whenever(carelevoPatch.userSettingInfo).thenReturn(userSettingSubject(userSettings)) + whenever { bleSession.runPairing(any(), any()) }.thenReturn(pairingResult) + // check(false) inside runCatching → IllegalStateException → onFailure arm. + whenever(connectNewPatchUseCase.persistNewPatch(any(), any(), any(), any(), any())).thenReturn(false) + + sut.startConnect(300) + awaitEvent() + + assertThat(events).contains(CarelevoConnectPrepareEvent.ConnectFailed) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startConnect reports failure when the pairing session throws and never persists`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + setSelectedDevice(device) + whenever(carelevoPatch.userSettingInfo).thenReturn(userSettingSubject(userSettings)) + whenever { bleSession.runPairing(any(), any()) }.thenAnswer { throw IllegalStateException("connect refused") } + + sut.startConnect(300) + awaitEvent() + + assertThat(events).contains(CarelevoConnectPrepareEvent.ConnectFailed) + verify(connectNewPatchUseCase, never()).persistNewPatch(any(), any(), any(), any(), any()) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + // ---- startPatchDiscardProcess: nothing to discard ------------------------------------------ + + @Test + fun `startPatchDiscardProcess completes immediately when no patch state is known`() { + whenever(carelevoPatch.patchState).thenReturn(patchStateSubject(null)) + + sut.startPatchDiscardProcess() + + assertThat(events).contains(CarelevoConnectPrepareEvent.DiscardComplete) + verifyBlocking(commandQueue, never()) { customCommand(any()) } + verify(patchForceDiscardUseCase, never()).execute() + } + + @Test + fun `startPatchDiscardProcess completes immediately when the patch is not booting`() { + whenever(carelevoPatch.patchState).thenReturn(patchStateSubject(PatchState.NotConnectedNotBooting)) + + sut.startPatchDiscardProcess() + + assertThat(events).contains(CarelevoConnectPrepareEvent.DiscardComplete) + verifyBlocking(commandQueue, never()) { customCommand(any()) } + } + + // ---- startPatchDiscardProcess: queued discard ---------------------------------------------- + + @Test + fun `startPatchDiscardProcess routes a booted patch through the command queue`() { + whenever(carelevoPatch.patchState).thenReturn(patchStateSubject(PatchState.ConnectedBooted)) + stubQueueResult(success = true) + + sut.startPatchDiscardProcess() + + assertThat(events).contains(CarelevoConnectPrepareEvent.DiscardComplete) + verifyBlocking(commandQueue) { customCommand(any()) } + // Queue succeeded → no DB-only fallback. + verify(patchForceDiscardUseCase, never()).execute() + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startPatchDiscardProcess routes a booted but disconnected patch through the command queue`() { + // NotConnectedBooted is still a real patch on the body — only the link is down, so the discard + // must go through the queue (which reconnects first), not straight to the DB-only fallback. + whenever(carelevoPatch.patchState).thenReturn(patchStateSubject(PatchState.NotConnectedBooted)) + stubQueueResult(success = true) + + sut.startPatchDiscardProcess() + + assertThat(events).contains(CarelevoConnectPrepareEvent.DiscardComplete) + verifyBlocking(commandQueue) { customCommand(any()) } + verify(patchForceDiscardUseCase, never()).execute() + } + + @Test + fun `startPatchDiscardProcess falls back to force-discard when the queue cannot reach the patch`() { + whenever(carelevoPatch.patchState).thenReturn(patchStateSubject(PatchState.ConnectedBooted)) + stubQueueResult(success = false) + stubForceDiscardSuccess() + + sut.startPatchDiscardProcess() + + assertThat(events).contains(CarelevoConnectPrepareEvent.DiscardComplete) + verify(patchForceDiscardUseCase).execute() + verify(carelevoPatch).discardTeardown() + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startPatchDiscardProcess reports failure when force-discard returns an error`() { + whenever(carelevoPatch.patchState).thenReturn(patchStateSubject(PatchState.ConnectedBooted)) + stubQueueResult(success = false) + whenever(patchForceDiscardUseCase.execute()).thenReturn(Single.just(ResponseResult.Error(RuntimeException("db down")))) + + sut.startPatchDiscardProcess() + + assertThat(events).contains(CarelevoConnectPrepareEvent.DiscardFailed) + verify(carelevoPatch, never()).discardTeardown() + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startPatchDiscardProcess reports failure when force-discard returns a failure result`() { + whenever(carelevoPatch.patchState).thenReturn(patchStateSubject(PatchState.ConnectedBooted)) + stubQueueResult(success = false) + // Neither Success nor Error → the `else` arm of the response when. + whenever(patchForceDiscardUseCase.execute()).thenReturn(Single.just(ResponseResult.Failure("rejected"))) + + sut.startPatchDiscardProcess() + + assertThat(events).contains(CarelevoConnectPrepareEvent.DiscardFailed) + verify(carelevoPatch, never()).discardTeardown() + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startPatchDiscardProcess reports failure when the force-discard stream errors`() { + whenever(carelevoPatch.patchState).thenReturn(patchStateSubject(PatchState.ConnectedBooted)) + stubQueueResult(success = false) + // Errored stream → the doOnError arm (not the subscribe consumer). + whenever(patchForceDiscardUseCase.execute()).thenReturn(Single.error(RuntimeException("boom"))) + + sut.startPatchDiscardProcess() + + assertThat(events).contains(CarelevoConnectPrepareEvent.DiscardFailed) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + // ---- onCleared ---------------------------------------------------------------------------- + + @Test + fun `onCleared disposes force-discard work still in flight`() { + whenever(carelevoPatch.patchState).thenReturn(patchStateSubject(PatchState.ConnectedBooted)) + stubQueueResult(success = false) + // Never terminates → the subscription stays parked in the VM's CompositeDisposable, which is + // exactly the state onCleared exists to clean up (the wizard step can be torn down mid-discard). + whenever(patchForceDiscardUseCase.execute()).thenReturn(Single.never()) + + sut.startPatchDiscardProcess() + assertThat(compositeDisposable().size()).isEqualTo(1) + + invokeOnCleared() + + assertThat(compositeDisposable().size()).isEqualTo(0) + } + + // ---- resetForEnterStep -------------------------------------------------------------------- + + @Test + fun `resetForEnterStep stops the scanner and clears the scan latch`() { + setScanWorking(true) + setSelectedDevice(device) + + sut.resetForEnterStep() + + verify(scanner).stopScan() + assertThat(sut.isScanWorking).isFalse() + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + + // The selection was dropped: re-entering the step must re-scan before connect is allowed. + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + sut.startConnect(300) + assertThat(events).contains(CarelevoConnectPrepareEvent.ShowMessageSelectedDeviceIseEmpty) + } + + // ---- triggerEvent / generateEventType ------------------------------------------------------ + + @Test + fun `triggerEvent forwards an unmapped connect-prepare event as NoAction`() { + // NoAction is the only subtype generateEventType does not list → the `else` arm. + sut.triggerEvent(CarelevoConnectPrepareEvent.NoAction) + + assertThat(events).containsExactly(CarelevoConnectPrepareEvent.NoAction) + } + + @Test + fun `triggerEvent ignores events from another screen`() { + sut.triggerEvent(CarelevoOverviewEvent.ShowPumpDiscardDialog) + + assertThat(events).isEmpty() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchConnectionFlowViewModelTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchConnectionFlowViewModelTest.kt new file mode 100644 index 000000000000..d2f019526839 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchConnectionFlowViewModelTest.kt @@ -0,0 +1,917 @@ +package app.aaps.pump.carelevo.presentation.viewmodel + +import app.aaps.core.data.model.ICfg +import app.aaps.core.data.model.PS +import app.aaps.core.data.model.TE +import app.aaps.core.data.ue.Sources +import app.aaps.core.interfaces.db.PersistenceLayer +import app.aaps.core.interfaces.insulin.InsulinManager +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.profile.EffectiveProfile +import app.aaps.core.interfaces.profile.ProfileFunction +import app.aaps.core.interfaces.profile.ProfileRepository +import app.aaps.core.interfaces.profile.ProfileStore +import app.aaps.core.interfaces.profile.SingleProfile +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.core.keys.BooleanKey +import app.aaps.core.keys.IntKey +import app.aaps.core.keys.interfaces.Preferences +import app.aaps.core.ui.compose.siteRotation.BodyType +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.common.model.Event +import app.aaps.pump.carelevo.common.model.PatchState +import app.aaps.pump.carelevo.common.model.UiState +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.result.ResultSuccess +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchForceDiscardUseCase +import app.aaps.pump.carelevo.presentation.model.CarelevoConnectEvent +import app.aaps.pump.carelevo.presentation.type.CarelevoPatchStep +import app.aaps.pump.carelevo.presentation.type.CarelevoScreenType +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.plugins.RxJavaPlugins +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.BehaviorSubject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Optional + +/** + * Unit tests for [CarelevoPatchConnectionFlowViewModel] — the activation wizard's step machine. + * + * **Why Robolectric and not a plain JVM/JUnit5 test:** every collaborator of this ViewModel is a + * mockable interface and construction touches none of them, so a JVM test would cover almost all of + * it. The single exception is [CarelevoPatchConnectionFlowViewModel.bodyType], which resolves + * [BodyType.fromPref]. Touching [BodyType] initialises its enum constants eagerly, and those embed + * `List>` zone tables — on a bare JVM `android.graphics.Path` + * is the "Stub!" class and enum class-init throws. Under [RobolectricTestRunner] `Path` is shadowed + * for real, so `bodyType()` is exercised like every other public member instead of being the one hole + * in the suite. No Android [android.content.Context] is needed otherwise. + * + * **Coroutines:** `viewModelScope` dispatches on `Dispatchers.Main`, so [testDispatcher] — an + * [UnconfinedTestDispatcher] — is installed as Main. Unconfined runs the VM's `viewModelScope.launch` + * bodies eagerly and in-line (the mocked `suspend` collaborators never really suspend), so a state + * transition has already settled by the time the call under test returns. Every test passes that same + * dispatcher to `runTest`, which makes the TestScope, the Main dispatcher and the event collector + * share one scheduler; `advanceUntilIdle()` is kept as a defensive flush. + * + * **Rx:** [AapsSchedulers.io] is stubbed to [Schedulers.trampoline] so the force-discard chain runs + * synchronously on the test thread. That chain's terminal `subscribe { }` has no `onError` arm, so an + * erroring source routes an `OnErrorNotImplementedException` to [RxJavaPlugins]; the global error + * handler is neutralised in [setUp] so the `doOnError` branch can be asserted without polluting the run. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class CarelevoPatchConnectionFlowViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private lateinit var aapsLogger: AAPSLogger + private lateinit var aapsSchedulers: AapsSchedulers + private lateinit var carelevoPatch: CarelevoPatch + private lateinit var commandQueue: CommandQueue + private lateinit var patchForceDiscardUseCase: CarelevoPatchForceDiscardUseCase + private lateinit var preferences: Preferences + private lateinit var profileFunction: ProfileFunction + private lateinit var profileRepository: ProfileRepository + private lateinit var insulinManager: InsulinManager + private lateinit var persistenceLayer: PersistenceLayer + + private lateinit var patchStateSubject: BehaviorSubject> + + private lateinit var sut: CarelevoPatchConnectionFlowViewModel + + private val fiasp = ICfg(insulinLabel = "Fiasp", peak = 55, dia = 6.0, concentration = 1.0) + private val lyumjev = ICfg(insulinLabel = "Lyumjev", peak = 45, dia = 5.0, concentration = 1.0) + + /** The step list the ViewModel starts with, before `initWorkflow` builds the real one. */ + private val defaultSteps = listOf( + CarelevoPatchStep.PATCH_START, + CarelevoPatchStep.PATCH_CONNECT, + CarelevoPatchStep.SAFETY_CHECK, + CarelevoPatchStep.PATCH_ATTACH, + CarelevoPatchStep.NEEDLE_INSERTION + ) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + // The force-discard chain ends in subscribe(onSuccess) with no onError arm; swallow the + // resulting undeliverable OnErrorNotImplementedException so the doOnError test stays quiet. + RxJavaPlugins.setErrorHandler { } + + aapsLogger = mock() + aapsSchedulers = mock() + carelevoPatch = mock() + commandQueue = mock() + patchForceDiscardUseCase = mock() + preferences = mock() + profileFunction = mock() + profileRepository = mock() + insulinManager = mock() + persistenceLayer = mock() + + whenever(aapsSchedulers.io).thenReturn(Schedulers.trampoline()) + whenever(aapsSchedulers.main).thenReturn(Schedulers.trampoline()) + + patchStateSubject = BehaviorSubject.create() + whenever(carelevoPatch.patchState).thenReturn(patchStateSubject) + + // Construction reads no collaborator — every field is a plain initialiser. + sut = CarelevoPatchConnectionFlowViewModel( + aapsLogger = aapsLogger, + aapsSchedulers = aapsSchedulers, + carelevoPatch = carelevoPatch, + commandQueue = commandQueue, + patchForceDiscardUseCase = patchForceDiscardUseCase, + preferences = preferences, + profileFunction = profileFunction, + profileRepository = profileRepository, + insulinManager = insulinManager, + persistenceLayer = persistenceLayer + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + RxJavaPlugins.reset() + } + + // ---- helpers ------------------------------------------------------------------------------ + + /** + * Subscribe to the VM's one-shot event flow *before* the action under test runs. The collector is + * launched on an [UnconfinedTestDispatcher] sharing the TestScope scheduler, so it registers on the + * underlying SharedFlow synchronously — an `EventFlow` slot is consumable exactly once, so + * subscribing first is what makes the emission observable. + */ + private fun TestScope.collectEvents(): MutableList { + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + sut.event.collect { events.add(it) } + } + return events + } + + /** Stub the collaborators `initWorkflow` fans out to. `suspend` stubs need a coroutine body. */ + private suspend fun stubWorkflow( + needsProfileGate: Boolean = false, + siteRotation: Boolean = false, + insulins: List = listOf(fiasp), + activeLabel: String? = "Fiasp" + ) { + val requested: PS? = if (needsProfileGate) null else mock() + whenever(profileFunction.getRequestedProfile()).thenReturn(requested) + whenever(preferences.get(BooleanKey.SiteRotationManagePump)).thenReturn(siteRotation) + whenever(insulinManager.insulins).thenReturn(ArrayList(insulins)) + val effective: EffectiveProfile? = + activeLabel?.let { label -> mock { on { iCfg } doReturn ICfg(label, 55, 6.0, 1.0) } } + whenever(profileFunction.getProfile()).thenReturn(effective) + } + + private suspend fun stubProfileList(names: List, originalName: String) { + val profiles = names.map { profileName -> mock { on { name } doReturn profileName } } + whenever(profileRepository.profiles).thenReturn(MutableStateFlow(profiles)) + whenever(profileFunction.getOriginalProfileName()).thenReturn(originalName) + } + + private fun te(eventType: TE.Type): TE = mock { on { type } doReturn eventType } + + /** + * Build + stub the enact result eagerly. It must be fully stubbed *before* it is handed to the + * `customCommand` stub — nesting a mock definition inside an in-flight `whenever()` trips + * `UnfinishedStubbingException`. + */ + private fun enactResult(success: Boolean): PumpEnactResult { + val result = mock() + whenever(result.success).thenReturn(success) + return result + } + + private fun connectedPatch() = patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + + /** All-matcher stub for the 11-arg `createProfileSwitch` overload the profile gate uses. */ + private suspend fun stubCreateProfileSwitch(result: PS?) { + whenever( + profileFunction.createProfileSwitch( + any(), any(), any(), any(), any(), any(), any(), any(), anyOrNull(), any(), any() + ) + ).thenReturn(result) + } + + private fun verifyNoProfileSwitchCreated() { + verifyBlocking(profileFunction, never()) { + createProfileSwitch(any(), any(), any(), any(), any(), any(), any(), any(), anyOrNull(), any(), any()) + } + } + + // ---- defaults ----------------------------------------------------------------------------- + + @Test + fun `initial state exposes the default step list at the first step`() { + assertThat(sut.page.value).isEqualTo(CarelevoPatchStep.PATCH_START) + assertThat(sut.currentStepIndex.value).isEqualTo(0) + assertThat(sut.totalSteps.value).isEqualTo(defaultSteps.size) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + assertThat(sut.inputInsulin).isEqualTo(300) + assertThat(sut.isCreated).isFalse() + } + + @Test + fun `initial host state is empty`() { + assertThat(sut.availableProfiles.value).isEmpty() + assertThat(sut.selectedProfile.value).isNull() + assertThat(sut.availableInsulins.value).isEmpty() + assertThat(sut.selectedInsulin.value).isNull() + assertThat(sut.activeInsulinLabel.value).isNull() + assertThat(sut.siteLocation.value).isEqualTo(TE.Location.NONE) + assertThat(sut.siteArrow.value).isEqualTo(TE.Arrow.NONE) + assertThat(sut.siteRotationEntries()).isEmpty() + assertThat(sut.showInsulinStep).isFalse() + } + + @Test + fun `setIsCreated flips the created latch both ways`() { + sut.setIsCreated(true) + assertThat(sut.isCreated).isTrue() + + sut.setIsCreated(false) + assertThat(sut.isCreated).isFalse() + } + + @Test + fun `concentrationEnabled reads the insulin concentration preference`() { + whenever(preferences.get(BooleanKey.GeneralInsulinConcentration)).thenReturn(true) + assertThat(sut.concentrationEnabled).isTrue() + + whenever(preferences.get(BooleanKey.GeneralInsulinConcentration)).thenReturn(false) + assertThat(sut.concentrationEnabled).isFalse() + } + + // ---- setPage / ordinal progress ----------------------------------------------------------- + + @Test + fun `setPage moves the page and reports the step ordinal within the workflow`() { + sut.setPage(CarelevoPatchStep.SAFETY_CHECK) + + assertThat(sut.page.value).isEqualTo(CarelevoPatchStep.SAFETY_CHECK) + assertThat(sut.currentStepIndex.value).isEqualTo(defaultSteps.indexOf(CarelevoPatchStep.SAFETY_CHECK)) + } + + @Test + fun `setPage to a step outside this run's workflow clamps the ordinal to zero`() { + sut.setPage(CarelevoPatchStep.NEEDLE_INSERTION) + assertThat(sut.currentStepIndex.value).isEqualTo(4) + + // PROFILE_GATE is not part of the default (pre-initWorkflow) list -> indexOf == -1 -> coerced. + sut.setPage(CarelevoPatchStep.PROFILE_GATE) + + assertThat(sut.page.value).isEqualTo(CarelevoPatchStep.PROFILE_GATE) + assertThat(sut.currentStepIndex.value).isEqualTo(0) + } + + @Test + fun `setInputInsulin stores the fill amount`() { + sut.setInputInsulin(180) + + assertThat(sut.inputInsulin).isEqualTo(180) + } + + // ---- initWorkflow ------------------------------------------------------------------------- + + @Test + fun `initWorkflow without gate, site rotation or insulin choice builds the minimal step list`() = runTest(testDispatcher) { + stubWorkflow(needsProfileGate = false, siteRotation = false, insulins = listOf(fiasp)) + + sut.initWorkflow(CarelevoScreenType.CONNECTION_FLOW_START) + advanceUntilIdle() + + assertThat(sut.totalSteps.value).isEqualTo(6) + assertThat(sut.page.value).isEqualTo(CarelevoPatchStep.PATCH_START) + assertThat(sut.currentStepIndex.value).isEqualTo(0) + assertThat(sut.showInsulinStep).isFalse() + assertThat(sut.availableProfiles.value).isEmpty() + // Site placement is reset every run so a previous run's choice cannot leak into this one. + verify(carelevoPatch).setSitePlacement(TE.Location.NONE, TE.Arrow.NONE) + // Site-rotation history is only loaded when the step is actually part of the run. + verifyBlocking(persistenceLayer, never()) { getTherapyEventDataFromTime(any(), any()) } + } + + @Test + fun `initWorkflow adds the profile gate, insulin choice and site location when all are needed`() = runTest(testDispatcher) { + stubWorkflow(needsProfileGate = true, siteRotation = true, insulins = listOf(fiasp, lyumjev)) + stubProfileList(names = listOf("Adult", "Night"), originalName = "Night") + val history = listOf(te(TE.Type.CANNULA_CHANGE), te(TE.Type.SENSOR_CHANGE), te(TE.Type.INSULIN_CHANGE)) + whenever(persistenceLayer.getTherapyEventDataFromTime(any(), any())).thenReturn(history) + + sut.initWorkflow(CarelevoScreenType.CONNECTION_FLOW_START) + advanceUntilIdle() + + assertThat(sut.totalSteps.value).isEqualTo(9) + assertThat(sut.showInsulinStep).isTrue() + // First entry of the built list -> the gate. + assertThat(sut.page.value).isEqualTo(CarelevoPatchStep.PROFILE_GATE) + assertThat(sut.currentStepIndex.value).isEqualTo(0) + assertThat(sut.availableProfiles.value).containsExactly("Adult", "Night").inOrder() + assertThat(sut.selectedProfile.value).isEqualTo("Night") + // Only cannula/sensor changes are site-rotation relevant; the insulin change is dropped. + assertThat(sut.siteRotationEntries()).hasSize(2) + } + + @Test + fun `initWorkflow falls back to the first profile when the active one is not in the list`() = runTest(testDispatcher) { + stubWorkflow(needsProfileGate = true) + stubProfileList(names = listOf("Adult", "Night"), originalName = "Deleted") + + sut.initWorkflow(CarelevoScreenType.CONNECTION_FLOW_START) + advanceUntilIdle() + + assertThat(sut.selectedProfile.value).isEqualTo("Adult") + } + + @Test + fun `initWorkflow selects the insulin matching the active profile and records its label`() = runTest(testDispatcher) { + stubWorkflow(insulins = listOf(fiasp, lyumjev), activeLabel = "Lyumjev") + + sut.initWorkflow(CarelevoScreenType.CONNECTION_FLOW_START) + advanceUntilIdle() + + assertThat(sut.availableInsulins.value.map { it.insulinLabel }).containsExactly("Fiasp", "Lyumjev").inOrder() + assertThat(sut.selectedInsulin.value?.insulinLabel).isEqualTo("Lyumjev") + assertThat(sut.activeInsulinLabel.value).isEqualTo("Lyumjev") + } + + @Test + fun `initWorkflow falls back to the first insulin when no profile is active`() = runTest(testDispatcher) { + stubWorkflow(insulins = listOf(fiasp, lyumjev), activeLabel = null) + + sut.initWorkflow(CarelevoScreenType.CONNECTION_FLOW_START) + advanceUntilIdle() + + assertThat(sut.selectedInsulin.value?.insulinLabel).isEqualTo("Fiasp") + assertThat(sut.activeInsulinLabel.value).isNull() + } + + @Test + fun `initWorkflow clones the insulins so editing the selection cannot corrupt InsulinManager`() = runTest(testDispatcher) { + stubWorkflow(insulins = listOf(fiasp)) + + sut.initWorkflow(CarelevoScreenType.CONNECTION_FLOW_START) + advanceUntilIdle() + + val exposed = sut.availableInsulins.value.single() + assertThat(exposed).isEqualTo(fiasp) + assertThat(exposed).isNotSameInstanceAs(fiasp) + } + + @Test + fun `initWorkflow keeps the already loaded insulins on a second run`() = runTest(testDispatcher) { + stubWorkflow(insulins = listOf(fiasp, lyumjev)) + sut.initWorkflow(CarelevoScreenType.CONNECTION_FLOW_START) + advanceUntilIdle() + + // Second pass: loadInsulins() short-circuits on a non-empty list, so a changed manager is ignored. + whenever(insulinManager.insulins).thenReturn(arrayListOf(ICfg("Novorapid", 75, 5.0, 1.0))) + sut.initWorkflow(CarelevoScreenType.CONNECTION_FLOW_START) + advanceUntilIdle() + + assertThat(sut.availableInsulins.value.map { it.insulinLabel }).containsExactly("Fiasp", "Lyumjev").inOrder() + } + + @Test + fun `initWorkflow resumes at the safety check for the safety check entry point`() = runTest(testDispatcher) { + stubWorkflow(siteRotation = false) + + sut.initWorkflow(CarelevoScreenType.SAFETY_CHECK) + advanceUntilIdle() + + assertThat(sut.page.value).isEqualTo(CarelevoPatchStep.SAFETY_CHECK) + assertThat(sut.currentStepIndex.value).isEqualTo(3) + } + + @Test + fun `initWorkflow resumes at patch attach for the needle insertion entry point`() = runTest(testDispatcher) { + stubWorkflow(siteRotation = false) + + sut.initWorkflow(CarelevoScreenType.NEEDLE_INSERTION) + advanceUntilIdle() + + assertThat(sut.page.value).isEqualTo(CarelevoPatchStep.PATCH_ATTACH) + assertThat(sut.currentStepIndex.value).isEqualTo(4) + } + + @Test + fun `initWorkflow resets a site placement chosen by a previous run`() = runTest(testDispatcher) { + stubWorkflow() + sut.updateSiteLocation(TE.Location.SIDE_RIGHT_UPPER_ARM) + assertThat(sut.siteLocation.value).isEqualTo(TE.Location.SIDE_RIGHT_UPPER_ARM) + + sut.initWorkflow(CarelevoScreenType.CONNECTION_FLOW_START) + advanceUntilIdle() + + assertThat(sut.siteLocation.value).isEqualTo(TE.Location.NONE) + assertThat(sut.siteArrow.value).isEqualTo(TE.Arrow.NONE) + } + + // ---- step advancement --------------------------------------------------------------------- + + @Test + fun `advanceFromSafetyCheck goes straight to patch attach when site rotation is off`() { + sut.advanceFromSafetyCheck() + + assertThat(sut.page.value).isEqualTo(CarelevoPatchStep.PATCH_ATTACH) + } + + @Test + fun `advanceFromSafetyCheck routes through site location when it is part of the run`() = runTest(testDispatcher) { + stubWorkflow(siteRotation = true) + whenever(persistenceLayer.getTherapyEventDataFromTime(any(), any())).thenReturn(emptyList()) + sut.initWorkflow(CarelevoScreenType.CONNECTION_FLOW_START) + advanceUntilIdle() + + sut.advanceFromSafetyCheck() + + assertThat(sut.page.value).isEqualTo(CarelevoPatchStep.SITE_LOCATION) + assertThat(sut.currentStepIndex.value).isEqualTo(4) + } + + @Test + fun `confirmAmount commits the fill amount and advances past the set amount step`() = runTest(testDispatcher) { + stubWorkflow() + sut.initWorkflow(CarelevoScreenType.CONNECTION_FLOW_START) + advanceUntilIdle() + + sut.confirmAmount(200) + + assertThat(sut.inputInsulin).isEqualTo(200) + assertThat(sut.page.value).isEqualTo(CarelevoPatchStep.PATCH_CONNECT) + assertThat(sut.currentStepIndex.value).isEqualTo(2) + } + + // ---- insulin selection -------------------------------------------------------------------- + + @Test + fun `selectInsulin records the chosen insulin`() { + sut.selectInsulin(lyumjev) + + assertThat(sut.selectedInsulin.value).isEqualTo(lyumjev) + } + + @Test + fun `advanceFromInsulin commits the profile switch before advancing when the insulin changed`() = runTest(testDispatcher) { + val effective = mock { on { iCfg } doReturn fiasp } + whenever(profileFunction.getProfile()).thenReturn(effective) + sut.selectInsulin(lyumjev) + + sut.advanceFromInsulin() + advanceUntilIdle() + + verifyBlocking(profileFunction) { createProfileSwitchWithNewInsulin(eq(lyumjev), eq(Sources.Carelevo)) } + } + + @Test + fun `advanceFromInsulin skips the profile switch when the active insulin is already selected`() = runTest(testDispatcher) { + val effective = mock { on { iCfg } doReturn fiasp } + whenever(profileFunction.getProfile()).thenReturn(effective) + sut.selectInsulin(fiasp) + + sut.advanceFromInsulin() + advanceUntilIdle() + + verifyBlocking(profileFunction, never()) { createProfileSwitchWithNewInsulin(any(), any()) } + } + + @Test + fun `advanceFromInsulin advances without touching the profile when nothing is selected`() = runTest(testDispatcher) { + sut.advanceFromInsulin() + advanceUntilIdle() + + verifyBlocking(profileFunction, never()) { createProfileSwitchWithNewInsulin(any(), any()) } + verifyBlocking(profileFunction, never()) { getProfile() } + } + + @Test + fun `advanceFromInsulin moves to the step following the insulin choice`() = runTest(testDispatcher) { + stubWorkflow(insulins = listOf(fiasp, lyumjev), activeLabel = "Fiasp") + sut.initWorkflow(CarelevoScreenType.CONNECTION_FLOW_START) + advanceUntilIdle() + assertThat(sut.page.value).isEqualTo(CarelevoPatchStep.SELECT_INSULIN) + + sut.advanceFromInsulin() + advanceUntilIdle() + + assertThat(sut.page.value).isEqualTo(CarelevoPatchStep.PATCH_START) + assertThat(sut.currentStepIndex.value).isEqualTo(1) + } + + // ---- ProfileGateStepHost ------------------------------------------------------------------ + + @Test + fun `selectProfile records the chosen profile name`() { + sut.selectProfile("Night") + + assertThat(sut.selectedProfile.value).isEqualTo("Night") + } + + @Test + fun `activateSelectedProfile creates the profile switch and advances past the gate`() = runTest(testDispatcher) { + stubWorkflow(needsProfileGate = true, insulins = listOf(fiasp)) + stubProfileList(names = listOf("Adult", "Night"), originalName = "Adult") + sut.initWorkflow(CarelevoScreenType.CONNECTION_FLOW_START) + advanceUntilIdle() + assertThat(sut.page.value).isEqualTo(CarelevoPatchStep.PROFILE_GATE) + + val store: ProfileStore = mock() + whenever(profileRepository.profile).thenReturn(MutableStateFlow(store)) + stubCreateProfileSwitch(mock()) + sut.selectProfile("Night") + + sut.activateSelectedProfile() + advanceUntilIdle() + + verifyBlocking(profileFunction) { + createProfileSwitch( + eq(store), eq("Night"), eq(0), eq(100), eq(0), any(), any(), eq(Sources.Carelevo), anyOrNull(), any(), any() + ) + } + assertThat(sut.page.value).isEqualTo(CarelevoPatchStep.PATCH_START) + } + + @Test + fun `activateSelectedProfile falls back to the first configured insulin when none is selected`() = runTest(testDispatcher) { + val store: ProfileStore = mock() + whenever(profileRepository.profile).thenReturn(MutableStateFlow(store)) + whenever(insulinManager.insulins).thenReturn(arrayListOf(fiasp, lyumjev)) + stubCreateProfileSwitch(mock()) + sut.selectProfile("Adult") + + sut.activateSelectedProfile() + advanceUntilIdle() + + verifyBlocking(profileFunction) { + createProfileSwitch(any(), eq("Adult"), any(), any(), any(), any(), any(), any(), anyOrNull(), any(), eq(fiasp)) + } + } + + @Test + fun `activateSelectedProfile logs and stays on the gate when the profile switch cannot be created`() = runTest(testDispatcher) { + stubWorkflow(needsProfileGate = true, insulins = listOf(fiasp)) + stubProfileList(names = listOf("Night"), originalName = "Night") + sut.initWorkflow(CarelevoScreenType.CONNECTION_FLOW_START) + advanceUntilIdle() + + val store: ProfileStore = mock() + whenever(profileRepository.profile).thenReturn(MutableStateFlow(store)) + stubCreateProfileSwitch(null) + + sut.activateSelectedProfile() + advanceUntilIdle() + + verify(aapsLogger).error(eq(LTag.PUMP), any()) + assertThat(sut.page.value).isEqualTo(CarelevoPatchStep.PROFILE_GATE) + } + + @Test + fun `activateSelectedProfile is a no-op when no profile is selected`() = runTest(testDispatcher) { + sut.activateSelectedProfile() + advanceUntilIdle() + + verifyNoProfileSwitchCreated() + } + + @Test + fun `activateSelectedProfile is a no-op when the profile store is empty`() = runTest(testDispatcher) { + whenever(profileRepository.profile).thenReturn(MutableStateFlow(null)) + sut.selectProfile("Night") + + sut.activateSelectedProfile() + advanceUntilIdle() + + verifyNoProfileSwitchCreated() + } + + @Test + fun `activateSelectedProfile is a no-op when no insulin is configured at all`() = runTest(testDispatcher) { + val store: ProfileStore = mock() + whenever(profileRepository.profile).thenReturn(MutableStateFlow(store)) + whenever(insulinManager.insulins).thenReturn(ArrayList()) + sut.selectProfile("Night") + + sut.activateSelectedProfile() + advanceUntilIdle() + + verifyNoProfileSwitchCreated() + } + + @Test + fun `cancelGate exits the wizard`() = runTest(testDispatcher) { + val events = collectEvents() + + sut.cancelGate() + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectEvent.ExitFlow) + } + + @Test + fun `exitWizard emits the exit flow event`() = runTest(testDispatcher) { + val events = collectEvents() + + sut.exitWizard() + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectEvent.ExitFlow) + } + + // ---- SiteLocationStepHost ----------------------------------------------------------------- + + @Test + fun `updateSiteLocation publishes the location to the patch keeping the current arrow`() { + sut.updateSiteLocation(TE.Location.SIDE_RIGHT_UPPER_ARM) + + assertThat(sut.siteLocation.value).isEqualTo(TE.Location.SIDE_RIGHT_UPPER_ARM) + verify(carelevoPatch).setSitePlacement(TE.Location.SIDE_RIGHT_UPPER_ARM, TE.Arrow.NONE) + } + + @Test + fun `updateSiteArrow publishes the arrow to the patch keeping the current location`() { + sut.updateSiteLocation(TE.Location.SIDE_RIGHT_UPPER_ARM) + + sut.updateSiteArrow(TE.Arrow.UP) + + assertThat(sut.siteArrow.value).isEqualTo(TE.Arrow.UP) + verify(carelevoPatch).setSitePlacement(TE.Location.SIDE_RIGHT_UPPER_ARM, TE.Arrow.UP) + } + + @Test + fun `completeSiteLocation republishes the placement and moves to patch attach`() { + sut.updateSiteLocation(TE.Location.SIDE_RIGHT_UPPER_ARM) + sut.updateSiteArrow(TE.Arrow.UP) + + sut.completeSiteLocation() + + assertThat(sut.page.value).isEqualTo(CarelevoPatchStep.PATCH_ATTACH) + // Once from updateSiteArrow, once from completeSiteLocation itself. + verify(carelevoPatch, times(2)).setSitePlacement(TE.Location.SIDE_RIGHT_UPPER_ARM, TE.Arrow.UP) + } + + @Test + fun `skipSiteLocation clears the placement and moves to patch attach`() { + sut.updateSiteLocation(TE.Location.SIDE_RIGHT_UPPER_ARM) + sut.updateSiteArrow(TE.Arrow.UP) + + sut.skipSiteLocation() + + assertThat(sut.siteLocation.value).isEqualTo(TE.Location.NONE) + assertThat(sut.siteArrow.value).isEqualTo(TE.Arrow.NONE) + assertThat(sut.page.value).isEqualTo(CarelevoPatchStep.PATCH_ATTACH) + verify(carelevoPatch).setSitePlacement(TE.Location.NONE, TE.Arrow.NONE) + } + + @Test + fun `bodyType maps the site rotation user profile preference`() { + whenever(preferences.get(IntKey.SiteRotationUserProfile)).thenReturn(BodyType.CHILD.value) + assertThat(sut.bodyType()).isEqualTo(BodyType.CHILD) + + whenever(preferences.get(IntKey.SiteRotationUserProfile)).thenReturn(BodyType.WOMAN.value) + assertThat(sut.bodyType()).isEqualTo(BodyType.WOMAN) + } + + @Test + fun `bodyType falls back to MAN for an unknown preference value`() { + whenever(preferences.get(IntKey.SiteRotationUserProfile)).thenReturn(99) + + assertThat(sut.bodyType()).isEqualTo(BodyType.MAN) + } + + // ---- triggerEvent ------------------------------------------------------------------------- + + @Test + fun `triggerEvent forwards the discard complete event verbatim`() = runTest(testDispatcher) { + val events = collectEvents() + + sut.triggerEvent(CarelevoConnectEvent.DiscardComplete) + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectEvent.DiscardComplete) + } + + @Test + fun `triggerEvent forwards the discard failed event verbatim`() = runTest(testDispatcher) { + val events = collectEvents() + + sut.triggerEvent(CarelevoConnectEvent.DiscardFailed) + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectEvent.DiscardFailed) + } + + @Test + fun `triggerEvent collapses an unmapped connect event to NoAction`() = runTest(testDispatcher) { + val events = collectEvents() + + sut.triggerEvent(CarelevoConnectEvent.NoAction) + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectEvent.NoAction) + } + + // ---- startPatchDiscardProcess ------------------------------------------------------------- + + @Test + fun `startPatchDiscardProcess completes immediately when there is no patch state yet`() = runTest(testDispatcher) { + val events = collectEvents() + + sut.startPatchDiscardProcess() + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectEvent.DiscardComplete) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verifyBlocking(commandQueue, never()) { customCommand(any()) } + verify(patchForceDiscardUseCase, never()).execute() + } + + @Test + fun `startPatchDiscardProcess completes immediately for a patch that was never booting`() = runTest(testDispatcher) { + patchStateSubject.onNext(Optional.of(PatchState.NotConnectedNotBooting)) + val events = collectEvents() + + sut.startPatchDiscardProcess() + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectEvent.DiscardComplete) + verifyBlocking(commandQueue, never()) { customCommand(any()) } + } + + @Test + fun `startPatchDiscardProcess routes an active patch through the command queue`() = runTest(testDispatcher) { + connectedPatch() + val enact = enactResult(success = true) + whenever(commandQueue.customCommand(any())).thenReturn(enact) + val events = collectEvents() + + sut.startPatchDiscardProcess() + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectEvent.DiscardComplete) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verifyBlocking(commandQueue) { customCommand(any()) } + // The queued CmdDiscard owns unBond + releasePatch; no DB-only fallback on the happy path. + verify(patchForceDiscardUseCase, never()).execute() + } + + @Test + fun `startPatchDiscardProcess falls back to force discard when the queued command fails`() = runTest(testDispatcher) { + connectedPatch() + val enact = enactResult(success = false) + whenever(commandQueue.customCommand(any())).thenReturn(enact) + val success: ResponseResult = ResponseResult.Success(ResultSuccess) + whenever(patchForceDiscardUseCase.execute()).thenReturn(Single.just(success)) + val events = collectEvents() + + sut.startPatchDiscardProcess() + advanceUntilIdle() + + verify(aapsLogger).error(eq(LTag.PUMPCOMM), any()) + verify(patchForceDiscardUseCase).execute() + verify(carelevoPatch).discardTeardown() + assertThat(events).containsExactly(CarelevoConnectEvent.DiscardComplete) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `force discard reports failure and skips teardown when the use case errors`() = runTest(testDispatcher) { + connectedPatch() + val enact = enactResult(success = false) + whenever(commandQueue.customCommand(any())).thenReturn(enact) + val error: ResponseResult = ResponseResult.Error(RuntimeException("boom")) + whenever(patchForceDiscardUseCase.execute()).thenReturn(Single.just(error)) + val events = collectEvents() + + sut.startPatchDiscardProcess() + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectEvent.DiscardFailed) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verify(carelevoPatch, never()).discardTeardown() + } + + @Test + fun `force discard reports failure and skips teardown on a non-success result`() = runTest(testDispatcher) { + connectedPatch() + val enact = enactResult(success = false) + whenever(commandQueue.customCommand(any())).thenReturn(enact) + val failure: ResponseResult = ResponseResult.Failure("rejected") + whenever(patchForceDiscardUseCase.execute()).thenReturn(Single.just(failure)) + val events = collectEvents() + + sut.startPatchDiscardProcess() + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectEvent.DiscardFailed) + verify(carelevoPatch, never()).discardTeardown() + } + + @Test + fun `force discard reports failure when the use case stream itself errors`() = runTest(testDispatcher) { + connectedPatch() + val enact = enactResult(success = false) + whenever(commandQueue.customCommand(any())).thenReturn(enact) + whenever(patchForceDiscardUseCase.execute()) + .thenReturn(Single.error>(RuntimeException("stream down"))) + val events = collectEvents() + + sut.startPatchDiscardProcess() + advanceUntilIdle() + + // doOnError arm: the UI is released and the failure surfaces to the wizard. + assertThat(events).containsExactly(CarelevoConnectEvent.DiscardFailed) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verify(carelevoPatch, never()).discardTeardown() + } + + // ---- residual guard branches -------------------------------------------------------------- + + @Test + fun `initWorkflow keeps an already valid profile selection instead of re-reading the active one`() = runTest(testDispatcher) { + stubWorkflow(needsProfileGate = true) + val profiles = listOf("Adult", "Night").map { profileName -> mock { on { name } doReturn profileName } } + whenever(profileRepository.profiles).thenReturn(MutableStateFlow(profiles)) + // A selection made before the gate is rebuilt is still valid, so it must survive verbatim. + sut.selectProfile("Night") + + sut.initWorkflow(CarelevoScreenType.CONNECTION_FLOW_START) + advanceUntilIdle() + + assertThat(sut.selectedProfile.value).isEqualTo("Night") + verifyBlocking(profileFunction, never()) { getOriginalProfileName() } + } + + @Test + fun `initWorkflow starts at the first step for the discard entry point`() = runTest(testDispatcher) { + stubWorkflow() + + sut.initWorkflow(CarelevoScreenType.PATCH_DISCARD) + advanceUntilIdle() + + assertThat(sut.page.value).isEqualTo(CarelevoPatchStep.PATCH_START) + assertThat(sut.currentStepIndex.value).isEqualTo(0) + } + + @Test + fun `advanceFromInsulin commits the switch when there is no active profile to compare against`() = runTest(testDispatcher) { + whenever(profileFunction.getProfile()).thenReturn(null) + sut.selectInsulin(lyumjev) + + sut.advanceFromInsulin() + advanceUntilIdle() + + // A null active label can never equal the selection, so the switch is committed before advancing. + verifyBlocking(profileFunction) { createProfileSwitchWithNewInsulin(eq(lyumjev), eq(Sources.Carelevo)) } + } + + @Test + fun `startPatchDiscardProcess completes immediately when the patch state optional is empty`() = runTest(testDispatcher) { + // Distinct from "no state yet": the subject has a value, but it carries no PatchState. + patchStateSubject.onNext(Optional.empty()) + val events = collectEvents() + + sut.startPatchDiscardProcess() + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectEvent.DiscardComplete) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verifyBlocking(commandQueue, never()) { customCommand(any()) } + verify(patchForceDiscardUseCase, never()).execute() + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchNeedleInsertionViewModelTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchNeedleInsertionViewModelTest.kt new file mode 100644 index 000000000000..ba0ae2250363 --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchNeedleInsertionViewModelTest.kt @@ -0,0 +1,881 @@ +package app.aaps.pump.carelevo.presentation.viewmodel + +import app.aaps.core.data.model.GlucoseUnit +import app.aaps.core.data.model.TE +import app.aaps.core.data.pump.defs.PumpType +import app.aaps.core.interfaces.db.PersistenceLayer +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.interfaces.profile.Profile +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.pump.PumpSync +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.pump.carelevo.command.CmdDiscard +import app.aaps.pump.carelevo.command.CmdNeedleCheck +import app.aaps.pump.carelevo.command.CmdSetBasal +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.common.model.Event +import app.aaps.pump.carelevo.common.model.PatchState +import app.aaps.pump.carelevo.common.model.UiState +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.alarm.CarelevoAlarmInfo +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.type.AlarmCause +import app.aaps.pump.carelevo.domain.type.AlarmType +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import app.aaps.pump.carelevo.domain.usecase.alarm.CarelevoAlarmInfoUseCase +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchForceDiscardUseCase +import app.aaps.pump.carelevo.presentation.model.AlarmEvent +import app.aaps.pump.carelevo.presentation.model.CarelevoConnectNeedleEvent +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.plugins.RxJavaPlugins +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.BehaviorSubject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Optional + +/** + * Unit tests for [CarelevoPatchNeedleInsertionViewModel] — the activation wizard's needle-insertion + * step. + * + * **Why Robolectric.** Every collaborator is mockable, but the therapy-event bookkeeping + * (`insertTherapyEventWithSingleRetry` / `insertCannulaChangeWithSite`) calls + * `android.os.SystemClock.sleep(...)` on its recovery path. Under a plain JVM unit test the + * mockable `android.jar` throws "Method sleep in android.os.SystemClock not mocked" (this project + * does not set `returnDefaultValues`), so the insert-retry branches would be untestable. Under + * [RobolectricTestRunner] `SystemClock.sleep` is shadowed and returns immediately, letting + * `startSetBasal success retries...` exercise the recovery inserts for real. Everything else + * behaves identically to a JVM test — no real Context is needed by this ViewModel. + * + * **Coroutines.** `viewModelScope` is backed by `Dispatchers.Main`, so a [StandardTestDispatcher] is + * installed as Main and its scheduler is handed to `runTest`, giving Main and the test body one + * shared virtual clock: `advanceUntilIdle()` then flushes the VM's `launch`ed work (including the + * `delay(1000)` after `connectNewPump`) before state is asserted. + * + * **Rx.** [AapsSchedulers] is stubbed to [Schedulers.trampoline] so `observePatchInfo` and the + * force-discard chain deliver synchronously on the test thread. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +@OptIn(ExperimentalCoroutinesApi::class) +class CarelevoPatchNeedleInsertionViewModelTest { + + private lateinit var aapsLogger: AAPSLogger + private lateinit var pumpSync: PumpSync + private lateinit var persistenceLayer: PersistenceLayer + private lateinit var aapsSchedulers: AapsSchedulers + private lateinit var carelevoPatch: CarelevoPatch + private lateinit var commandQueue: CommandQueue + private lateinit var patchForceDiscardUseCase: CarelevoPatchForceDiscardUseCase + private lateinit var carelevoAlarmInfoUseCase: CarelevoAlarmInfoUseCase + + // The BehaviorSubjects CarelevoPatch exposes; `.value` is what the VM reads for serial / + // fail-count / patch-state, and onNext() drives observePatchInfo's subscription. + private lateinit var patchInfoSubject: BehaviorSubject> + private lateinit var patchStateSubject: BehaviorSubject> + private lateinit var profileSubject: BehaviorSubject> + + private lateinit var testDispatcher: TestDispatcher + private lateinit var sut: CarelevoPatchNeedleInsertionViewModel + + @Before + fun setUp() { + aapsLogger = mock() + pumpSync = mock() + persistenceLayer = mock() + aapsSchedulers = mock() + carelevoPatch = mock() + commandQueue = mock() + patchForceDiscardUseCase = mock() + carelevoAlarmInfoUseCase = mock() + + patchInfoSubject = BehaviorSubject.create() + patchStateSubject = BehaviorSubject.create() + profileSubject = BehaviorSubject.create() + + whenever(aapsSchedulers.io).thenReturn(Schedulers.trampoline()) + whenever(aapsSchedulers.main).thenReturn(Schedulers.trampoline()) + whenever(carelevoPatch.patchInfo).thenReturn(patchInfoSubject) + whenever(carelevoPatch.patchState).thenReturn(patchStateSubject) + whenever(carelevoPatch.profile).thenReturn(profileSubject) + // Wizard default: site rotation step skipped -> no location/arrow to patch onto the event. + whenever(carelevoPatch.sitePlacementLocation).thenReturn(TE.Location.NONE) + whenever(carelevoPatch.sitePlacementArrow).thenReturn(TE.Arrow.NONE) + + // The force-discard chain ends in a single-arg subscribe(onSuccess); an errored source there + // routes an OnErrorNotImplementedException to the global handler. Swallow it so the + // doOnError branch can be tested without polluting the run. + RxJavaPlugins.setErrorHandler { } + + testDispatcher = StandardTestDispatcher() + Dispatchers.setMain(testDispatcher) + + sut = CarelevoPatchNeedleInsertionViewModel( + aapsLogger = aapsLogger, + pumpSync = pumpSync, + persistenceLayer = persistenceLayer, + aapsSchedulers = aapsSchedulers, + carelevoPatch = carelevoPatch, + commandQueue = commandQueue, + patchForceDiscardUseCase = patchForceDiscardUseCase, + carelevoAlarmInfoUseCase = carelevoAlarmInfoUseCase + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + RxJavaPlugins.setErrorHandler(null) + } + + // ---- helpers ------------------------------------------------------------------------------ + + /** + * Build the stubbed result mock BEFORE it is handed to thenReturn — stubbing another mock inside + * a thenReturn argument trips Mockito's UnfinishedStubbingException. + */ + private fun enactResult(success: Boolean): PumpEnactResult = mock().also { + whenever(it.success).thenReturn(success) + } + + private fun patchInfo( + checkNeedle: Boolean? = null, + needleFailedCount: Int? = null, + manufactureNumber: String? = "SN-1" + ) = CarelevoPatchInfoDomainModel( + address = "AA:BB:CC:DD:EE:FF", + manufactureNumber = manufactureNumber, + checkNeedle = checkNeedle, + needleFailedCount = needleFailedCount + ) + + private fun therapyEvent(type: TE.Type = TE.Type.CANNULA_CHANGE) = + TE(timestamp = 1_000L, type = type, glucoseUnit = GlucoseUnit.MGDL) + + /** + * Subscribe to the VM's one-shot event flow for the whole test. The collector runs on an + * unconfined test dispatcher so it is subscribed before any action is triggered (the flow is + * consume-once, so exactly one collector must own the emissions). + */ + private fun TestScope.collectEvents(): MutableList { + val events = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + sut.event.collect { events += it } + } + return events + } + + /** Bluetooth on + a profile set: the happy-path preconditions for startSetBasal. */ + private fun givenReadyToSetBasal() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + profileSubject.onNext(Optional.of(mock())) + } + + // ---- simple state / accessors ------------------------------------------------------------- + + @Test + fun `isCreated defaults to false and follows setIsCreated`() { + assertThat(sut.isCreated).isFalse() + + sut.setIsCreated(true) + assertThat(sut.isCreated).isTrue() + + sut.setIsCreated(false) + assertThat(sut.isCreated).isFalse() + } + + @Test + fun `initial state is Idle with the needle not inserted`() { + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + assertThat(sut.isNeedleInsert.value).isFalse() + } + + @Test + fun `needleFailCount is null when there is no patch info`() { + assertThat(sut.needleFailCount()).isNull() + } + + @Test + fun `needleFailCount reads the count off the patch info`() { + patchInfoSubject.onNext(Optional.of(patchInfo(needleFailedCount = 2))) + + assertThat(sut.needleFailCount()).isEqualTo(2) + } + + // ---- triggerEvent ------------------------------------------------------------------------- + + @Test + fun `triggerEvent forwards a needle event to the event flow`() = runTest(testDispatcher.scheduler) { + val events = collectEvents() + + sut.triggerEvent(CarelevoConnectNeedleEvent.CheckNeedleComplete(true)) + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.CheckNeedleComplete(true)) + } + + @Test + fun `triggerEvent maps an unmapped needle event to NoAction`() = runTest(testDispatcher.scheduler) { + // NoAction is not listed in generateEventType's when -> falls through to the else branch. + val events = collectEvents() + + sut.triggerEvent(CarelevoConnectNeedleEvent.NoAction) + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.NoAction) + } + + @Test + fun `triggerEvent forwards the not-connected message to the event flow`() = runTest(testDispatcher.scheduler) { + // ShowMessageCarelevoIsNotConnected has its own generateEventType branch but no VM caller; + // it is only reachable through triggerEvent from the step composable. + val events = collectEvents() + + sut.triggerEvent(CarelevoConnectNeedleEvent.ShowMessageCarelevoIsNotConnected) + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.ShowMessageCarelevoIsNotConnected) + } + + @Test + fun `triggerEvent ignores an event from another hierarchy`() = runTest(testDispatcher.scheduler) { + val events = collectEvents() + + sut.triggerEvent(AlarmEvent.NoAction) + advanceUntilIdle() + + assertThat(events).isEmpty() + } + + // ---- observePatchInfo --------------------------------------------------------------------- + + @Test + fun `observePatchInfo marks the needle as inserted`() { + sut.observePatchInfo() + + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = true, needleFailedCount = 0))) + + assertThat(sut.isNeedleInsert.value).isTrue() + } + + @Test + fun `observePatchInfo clears the needle flag when the patch reports it withdrawn`() { + sut.observePatchInfo() + + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = true, needleFailedCount = 0))) + assertThat(sut.isNeedleInsert.value).isTrue() + + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = false, needleFailedCount = 0))) + assertThat(sut.isNeedleInsert.value).isFalse() + } + + @Test + fun `observePatchInfo keeps the first insert timestamp across repeated inserted reports`() { + // The patch re-reports checkNeedle=true on every poll; the insert instant must be latched on + // the first one (`if (needleInsertedAtMs == null)`) so the needle-to-basal delay is measured + // from the real insertion, not from the latest poll. + sut.observePatchInfo() + givenReadyToSetBasal() + + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = true, needleFailedCount = 0))) + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = true, needleFailedCount = 0))) + + sut.startSetBasal() + testDispatcher.scheduler.runCurrent() + + // Still inside the latched delay window -> parked, not programmed. + assertThat(sut.isNeedleInsert.value).isTrue() + assertThat(sut.uiState.value).isEqualTo(UiState.Loading) + verifyBlocking(commandQueue, never()) { customCommand(any()) } + } + + @Test + fun `observePatchInfo withdrawal cancels a parked delayed basal job`() { + // Park a delayed startSetBasal, then report the needle withdrawn. The withdrawal clears the + // insert instant AND cancels the job; if it only cleared the instant, the still-armed job + // would fire below and program the basal against a withdrawn needle. + givenReadyToSetBasal() + val ok = enactResult(true) + whenever { commandQueue.customCommand(any()) }.thenReturn(ok) + sut.observePatchInfo() + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = true, needleFailedCount = 0))) + + sut.startSetBasal() + testDispatcher.scheduler.runCurrent() + assertThat(sut.uiState.value).isEqualTo(UiState.Loading) + + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = false, needleFailedCount = 0))) + // Advance past NEEDLE_TO_BASAL_DELAY_MS by a bounded amount: a cancelled job never fires, an + // armed one would re-enter startSetBasal with a null insert instant and reach the queue. + testDispatcher.scheduler.advanceTimeBy(11_000L) + testDispatcher.scheduler.runCurrent() + + assertThat(sut.isNeedleInsert.value).isFalse() + verifyBlocking(commandQueue, never()) { customCommand(any()) } + } + + @Test + fun `observePatchInfo ignores an empty patch info`() { + sut.observePatchInfo() + + patchInfoSubject.onNext(Optional.empty()) + + assertThat(sut.isNeedleInsert.value).isFalse() + verify(carelevoAlarmInfoUseCase, never()).upsertAlarm(any()) + } + + @Test + fun `observePatchInfo raises a needle-insertion alarm and reports the failure at three attempts`() = + runTest(testDispatcher.scheduler) { + whenever(carelevoAlarmInfoUseCase.upsertAlarm(any())).thenReturn(Completable.complete()) + val events = collectEvents() + sut.observePatchInfo() + + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = false, needleFailedCount = 3))) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(carelevoAlarmInfoUseCase).upsertAlarm(captor.capture()) + assertThat(captor.firstValue.cause).isEqualTo(AlarmCause.ALARM_WARNING_NEEDLE_INSERTION_ERROR) + assertThat(captor.firstValue.alarmType).isEqualTo(AlarmType.WARNING) + assertThat(captor.firstValue.isAcknowledged).isFalse() + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.CheckNeedleFailed(3)) + } + + @Test + fun `observePatchInfo does not alarm below three needle failures`() { + sut.observePatchInfo() + + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = false, needleFailedCount = 2))) + + verify(carelevoAlarmInfoUseCase, never()).upsertAlarm(any()) + } + + @Test + fun `observePatchInfo logs when the alarm upsert fails`() { + whenever(carelevoAlarmInfoUseCase.upsertAlarm(any())).thenReturn(Completable.error(RuntimeException("boom"))) + sut.observePatchInfo() + + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = false, needleFailedCount = 3))) + + verify(aapsLogger).error(eq(LTag.PUMPCOMM), any()) + } + + // ---- startCheckNeedle --------------------------------------------------------------------- + + @Test + fun `startCheckNeedle without bluetooth reports it and never reaches the queue`() = + runTest(testDispatcher.scheduler) { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(false) + val events = collectEvents() + + sut.startCheckNeedle() + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.ShowMessageBluetoothNotEnabled) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verifyBlocking(commandQueue, never()) { customCommand(any()) } + } + + @Test + fun `startCheckNeedle success runs the check on the queue and reports completion`() = + runTest(testDispatcher.scheduler) { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + val ok = enactResult(true) + whenever { commandQueue.customCommand(any()) }.thenReturn(ok) + val events = collectEvents() + + sut.startCheckNeedle() + advanceUntilIdle() + + verifyBlocking(commandQueue) { customCommand(any()) } + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.CheckNeedleComplete(true)) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startCheckNeedle failure with a recorded fail count reports CheckNeedleFailed`() = + runTest(testDispatcher.scheduler) { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + val fail = enactResult(false) + whenever { commandQueue.customCommand(any()) }.thenReturn(fail) + // A failed check leaves the count on the patch. + patchInfoSubject.onNext(Optional.of(patchInfo(needleFailedCount = 2))) + val events = collectEvents() + + sut.startCheckNeedle() + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.CheckNeedleFailed(2)) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startCheckNeedle failure without a fail count reports a generic error`() = + runTest(testDispatcher.scheduler) { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + val fail = enactResult(false) + whenever { commandQueue.customCommand(any()) }.thenReturn(fail) + // No patch info at all -> the queue never reached the patch -> no count to report. + val events = collectEvents() + + sut.startCheckNeedle() + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.CheckNeedleError) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + // ---- startSetBasal: guards ---------------------------------------------------------------- + + @Test + fun `startSetBasal without bluetooth reports it and returns to Idle`() = + runTest(testDispatcher.scheduler) { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(false) + val events = collectEvents() + + sut.startSetBasal() + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.ShowMessageBluetoothNotEnabled) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verifyBlocking(commandQueue, never()) { customCommand(any()) } + } + + @Test + fun `startSetBasal without a profile reports it and returns to Idle`() = + runTest(testDispatcher.scheduler) { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + profileSubject.onNext(Optional.empty()) + val events = collectEvents() + + sut.startSetBasal() + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.ShowMessageProfileNotSet) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verifyBlocking(commandQueue, never()) { customCommand(any()) } + } + + @Test + fun `startSetBasal waits out the needle-to-basal delay before touching the queue`() { + // A needle inserted just now leaves ~10s of NEEDLE_TO_BASAL_DELAY_MS to run: the call must + // park in a delayed job (Loading) instead of programming the basal immediately. + sut.observePatchInfo() + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = true, needleFailedCount = 0))) + + sut.startSetBasal() + // runCurrent() flushes the setUiState launch but deliberately does NOT advance virtual time, + // so the parked delayed job never fires. (advanceUntilIdle would re-enter startSetBasal, + // which re-arms the job because the *wall* clock the guard reads has not moved.) + testDispatcher.scheduler.runCurrent() + + assertThat(sut.uiState.value).isEqualTo(UiState.Loading) + verify(carelevoPatch, never()).isBluetoothEnabled() + verifyBlocking(commandQueue, never()) { customCommand(any()) } + } + + @Test + fun `startSetBasal runs immediately once the needle has been withdrawn`() = + runTest(testDispatcher.scheduler) { + // Insert then withdraw clears the insert instant, so the needle-to-basal delay guard is + // skipped entirely and the basal is programmed on the spot (no parked job). + sut.observePatchInfo() + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = true, needleFailedCount = 0))) + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = false, needleFailedCount = 0))) + givenReadyToSetBasal() + val ok = enactResult(true) + whenever { commandQueue.customCommand(any()) }.thenReturn(ok) + whenever { + pumpSync.insertTherapyEventIfNewWithTimestamp(any(), any(), anyOrNull(), anyOrNull(), any(), any()) + }.thenReturn(true) + val events = collectEvents() + + sut.startSetBasal() + advanceUntilIdle() + + verifyBlocking(commandQueue) { customCommand(any()) } + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.SetBasalComplete) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + // ---- startSetBasal: success bookkeeping --------------------------------------------------- + + @Test + fun `startSetBasal success syncs the pump and records both therapy events`() = + runTest(testDispatcher.scheduler) { + givenReadyToSetBasal() + patchInfoSubject.onNext(Optional.of(patchInfo(manufactureNumber = "SN-1"))) + val ok = enactResult(true) + whenever { commandQueue.customCommand(any()) }.thenReturn(ok) + whenever { + pumpSync.insertTherapyEventIfNewWithTimestamp(any(), any(), anyOrNull(), anyOrNull(), any(), any()) + }.thenReturn(true) + val events = collectEvents() + + sut.startSetBasal() + advanceUntilIdle() + + verifyBlocking(commandQueue) { customCommand(any()) } + verify(pumpSync).connectNewPump(true) + verifyBlocking(pumpSync) { + insertTherapyEventIfNewWithTimestamp( + any(), eq(TE.Type.CANNULA_CHANGE), anyOrNull(), anyOrNull(), + eq(PumpType.CAREMEDI_CARELEVO), eq("SN-1") + ) + } + verifyBlocking(pumpSync) { + insertTherapyEventIfNewWithTimestamp( + any(), eq(TE.Type.INSULIN_CHANGE), anyOrNull(), anyOrNull(), + eq(PumpType.CAREMEDI_CARELEVO), eq("SN-1") + ) + } + // Site rotation skipped -> no therapy event is looked up or patched. + verifyBlocking(persistenceLayer, never()) { getTherapyEventDataFromToTime(any(), any()) } + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.SetBasalComplete) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startSetBasal falls back to an empty serial when the patch info has none`() = + runTest(testDispatcher.scheduler) { + givenReadyToSetBasal() + // No patch info cached -> manufactureNumber elvis -> "". + val ok = enactResult(true) + whenever { commandQueue.customCommand(any()) }.thenReturn(ok) + whenever { + pumpSync.insertTherapyEventIfNewWithTimestamp(any(), any(), anyOrNull(), anyOrNull(), any(), any()) + }.thenReturn(true) + + sut.startSetBasal() + advanceUntilIdle() + + verifyBlocking(pumpSync) { + insertTherapyEventIfNewWithTimestamp( + any(), eq(TE.Type.CANNULA_CHANGE), anyOrNull(), anyOrNull(), any(), eq("") + ) + } + } + + @Test + fun `startSetBasal success patches the chosen site location onto the cannula-change event`() = + runTest(testDispatcher.scheduler) { + givenReadyToSetBasal() + whenever(carelevoPatch.sitePlacementLocation).thenReturn(TE.Location.SIDE_LEFT_UPPER_ARM) + whenever(carelevoPatch.sitePlacementArrow).thenReturn(TE.Arrow.DOWN) + val ok = enactResult(true) + whenever { commandQueue.customCommand(any()) }.thenReturn(ok) + whenever { + pumpSync.insertTherapyEventIfNewWithTimestamp(any(), any(), anyOrNull(), anyOrNull(), any(), any()) + }.thenReturn(true) + whenever { persistenceLayer.getTherapyEventDataFromToTime(any(), any()) } + .thenReturn(listOf(therapyEvent(TE.Type.CANNULA_CHANGE))) + val events = collectEvents() + + sut.startSetBasal() + advanceUntilIdle() + + val captor = argumentCaptor() + verifyBlocking(persistenceLayer) { insertOrUpdateTherapyEvent(captor.capture()) } + assertThat(captor.firstValue.type).isEqualTo(TE.Type.CANNULA_CHANGE) + assertThat(captor.firstValue.location).isEqualTo(TE.Location.SIDE_LEFT_UPPER_ARM) + assertThat(captor.firstValue.arrow).isEqualTo(TE.Arrow.DOWN) + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.SetBasalComplete) + } + + @Test + fun `startSetBasal does not patch a site location when no cannula-change event is found`() = + runTest(testDispatcher.scheduler) { + givenReadyToSetBasal() + whenever(carelevoPatch.sitePlacementLocation).thenReturn(TE.Location.SIDE_LEFT_UPPER_ARM) + whenever(carelevoPatch.sitePlacementArrow).thenReturn(TE.Arrow.DOWN) + val ok = enactResult(true) + whenever { commandQueue.customCommand(any()) }.thenReturn(ok) + whenever { + pumpSync.insertTherapyEventIfNewWithTimestamp(any(), any(), anyOrNull(), anyOrNull(), any(), any()) + }.thenReturn(true) + // Only a non-cannula event in the window -> firstOrNull{} finds nothing. + whenever { persistenceLayer.getTherapyEventDataFromToTime(any(), any()) } + .thenReturn(listOf(therapyEvent(TE.Type.INSULIN_CHANGE))) + + sut.startSetBasal() + advanceUntilIdle() + + verifyBlocking(persistenceLayer, never()) { insertOrUpdateTherapyEvent(any()) } + } + + @Test + fun `startSetBasal still completes when patching the site location fails`() = + runTest(testDispatcher.scheduler) { + givenReadyToSetBasal() + whenever(carelevoPatch.sitePlacementLocation).thenReturn(TE.Location.SIDE_LEFT_UPPER_ARM) + whenever(carelevoPatch.sitePlacementArrow).thenReturn(TE.Arrow.DOWN) + val ok = enactResult(true) + whenever { commandQueue.customCommand(any()) }.thenReturn(ok) + whenever { + pumpSync.insertTherapyEventIfNewWithTimestamp(any(), any(), anyOrNull(), anyOrNull(), any(), any()) + }.thenReturn(true) + whenever { persistenceLayer.getTherapyEventDataFromToTime(any(), any()) } + .thenThrow(RuntimeException("db down")) + val events = collectEvents() + + sut.startSetBasal() + advanceUntilIdle() + + // Site location is optional bookkeeping: the failure is swallowed, basal still succeeded. + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.SetBasalComplete) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startSetBasal success retries each therapy event once when the first insert is rejected`() = + runTest(testDispatcher.scheduler) { + givenReadyToSetBasal() + patchInfoSubject.onNext(Optional.of(patchInfo(manufactureNumber = "SN-1"))) + val ok = enactResult(true) + whenever { commandQueue.customCommand(any()) }.thenReturn(ok) + // Never accepted -> both inserts take the SystemClock.sleep recovery path (Robolectric). + whenever { + pumpSync.insertTherapyEventIfNewWithTimestamp(any(), any(), anyOrNull(), anyOrNull(), any(), any()) + }.thenReturn(false) + val events = collectEvents() + + sut.startSetBasal() + advanceUntilIdle() + + verifyBlocking(pumpSync, times(2)) { + insertTherapyEventIfNewWithTimestamp( + any(), eq(TE.Type.CANNULA_CHANGE), anyOrNull(), anyOrNull(), any(), any() + ) + } + verifyBlocking(pumpSync, times(2)) { + insertTherapyEventIfNewWithTimestamp( + any(), eq(TE.Type.INSULIN_CHANGE), anyOrNull(), anyOrNull(), any(), any() + ) + } + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.SetBasalComplete) + } + + @Test + fun `startSetBasal failure reports it and skips all pump bookkeeping`() = + runTest(testDispatcher.scheduler) { + givenReadyToSetBasal() + val fail = enactResult(false) + whenever { commandQueue.customCommand(any()) }.thenReturn(fail) + val events = collectEvents() + + sut.startSetBasal() + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.SetBasalFailed) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verify(pumpSync, never()).connectNewPump(any()) + verifyBlocking(pumpSync, never()) { + insertTherapyEventIfNewWithTimestamp(any(), any(), anyOrNull(), anyOrNull(), any(), any()) + } + } + + // ---- startDiscardProcess ------------------------------------------------------------------ + + @Test + fun `startDiscardProcess with no patch completes without touching the queue`() = + runTest(testDispatcher.scheduler) { + patchStateSubject.onNext(Optional.of(PatchState.NotConnectedNotBooting)) + val events = collectEvents() + + sut.startDiscardProcess() + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.DiscardComplete) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verifyBlocking(commandQueue, never()) { customCommand(any()) } + } + + @Test + fun `startDiscardProcess with an unknown patch state completes without touching the queue`() = + runTest(testDispatcher.scheduler) { + // patchState never emitted -> value is null -> same branch as NotConnectedNotBooting. + val events = collectEvents() + + sut.startDiscardProcess() + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.DiscardComplete) + verifyBlocking(commandQueue, never()) { customCommand(any()) } + } + + @Test + fun `startDiscardProcess success discards through the queue`() = + runTest(testDispatcher.scheduler) { + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + val ok = enactResult(true) + whenever { commandQueue.customCommand(any()) }.thenReturn(ok) + val events = collectEvents() + + sut.startDiscardProcess() + advanceUntilIdle() + + verifyBlocking(commandQueue) { customCommand(any()) } + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.DiscardComplete) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verify(patchForceDiscardUseCase, never()).execute() + } + + @Test + fun `startDiscardProcess falls back to a force discard when the queue cannot reach the patch`() = + runTest(testDispatcher.scheduler) { + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + val fail = enactResult(false) + whenever { commandQueue.customCommand(any()) }.thenReturn(fail) + whenever(patchForceDiscardUseCase.execute()) + .thenReturn( + Single.just>( + ResponseResult.Success(null) + ) + ) + val events = collectEvents() + + sut.startDiscardProcess() + advanceUntilIdle() + + verify(aapsLogger).error(eq(LTag.PUMPCOMM), any()) + verify(carelevoPatch).discardTeardown() + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.DiscardComplete) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `startDiscardProcess reports failure when the force discard errors`() = + runTest(testDispatcher.scheduler) { + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + val fail = enactResult(false) + whenever { commandQueue.customCommand(any()) }.thenReturn(fail) + whenever(patchForceDiscardUseCase.execute()) + .thenReturn( + Single.just>(ResponseResult.Error(RuntimeException("boom"))) + ) + val events = collectEvents() + + sut.startDiscardProcess() + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.DiscardFailed) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verify(carelevoPatch, never()).discardTeardown() + } + + @Test + fun `startDiscardProcess reports failure when the force discard returns a failure response`() = + runTest(testDispatcher.scheduler) { + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + val fail = enactResult(false) + whenever { commandQueue.customCommand(any()) }.thenReturn(fail) + whenever(patchForceDiscardUseCase.execute()) + .thenReturn(Single.just>(ResponseResult.Failure("nope"))) + val events = collectEvents() + + sut.startDiscardProcess() + advanceUntilIdle() + + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.DiscardFailed) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verify(carelevoPatch, never()).discardTeardown() + } + + @Test + fun `startDiscardProcess reports failure when the force discard stream errors`() = + runTest(testDispatcher.scheduler) { + patchStateSubject.onNext(Optional.of(PatchState.ConnectedBooted)) + val fail = enactResult(false) + whenever { commandQueue.customCommand(any()) }.thenReturn(fail) + whenever(patchForceDiscardUseCase.execute()) + .thenReturn(Single.error>(RuntimeException("boom"))) + val events = collectEvents() + + sut.startDiscardProcess() + advanceUntilIdle() + + // doOnError branch of the force-discard chain. + assertThat(events).containsExactly(CarelevoConnectNeedleEvent.DiscardFailed) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verify(carelevoPatch, never()).discardTeardown() + } + + // ---- onCleared ---------------------------------------------------------------------------- + + /** onCleared is protected on ViewModel and the VM is final, so drive it reflectively. */ + private fun clearViewModel() { + val onCleared = sut.javaClass.getDeclaredMethod("onCleared") + onCleared.isAccessible = true + onCleared.invoke(sut) + } + + @Test + fun `onCleared disposes the patch-info subscription so later emissions are ignored`() { + sut.observePatchInfo() + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = true, needleFailedCount = 0))) + assertThat(sut.isNeedleInsert.value).isTrue() + + clearViewModel() + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = false, needleFailedCount = 0))) + + // Subscription disposed on teardown -> the withdrawal never reaches the flow. + assertThat(sut.isNeedleInsert.value).isTrue() + } + + @Test + fun `onCleared does not alarm on a post-teardown needle failure`() { + sut.observePatchInfo() + + clearViewModel() + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = false, needleFailedCount = 3))) + + verify(carelevoAlarmInfoUseCase, never()).upsertAlarm(any()) + } + + @Test + fun `onCleared tears down a parked delayed basal job without throwing`() { + // Coverage of the delayedStartBasalJob.cancel() line. Deliberately no queue assertion: the + // delay guard reads the wall clock, which the virtual scheduler cannot advance, so an + // un-cancelled job would merely re-park — the cancel is not observable through the queue. + givenReadyToSetBasal() + sut.observePatchInfo() + patchInfoSubject.onNext(Optional.of(patchInfo(checkNeedle = true, needleFailedCount = 0))) + sut.startSetBasal() + testDispatcher.scheduler.runCurrent() + assertThat(sut.uiState.value).isEqualTo(UiState.Loading) + + clearViewModel() + + verifyBlocking(commandQueue, never()) { customCommand(any()) } + } +} diff --git a/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchSafetyCheckViewModelTest.kt b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchSafetyCheckViewModelTest.kt new file mode 100644 index 000000000000..b71ebd5c2fbc --- /dev/null +++ b/pump/carelevo/src/test/kotlin/app/aaps/pump/carelevo/presentation/viewmodel/CarelevoPatchSafetyCheckViewModelTest.kt @@ -0,0 +1,814 @@ +package app.aaps.pump.carelevo.presentation.viewmodel + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.pump.PumpEnactResult +import app.aaps.core.interfaces.queue.CommandQueue +import app.aaps.core.interfaces.rx.AapsSchedulers +import app.aaps.pump.carelevo.command.CarelevoActivationExecutor +import app.aaps.pump.carelevo.command.CmdAdditionalPriming +import app.aaps.pump.carelevo.command.CmdDiscard +import app.aaps.pump.carelevo.command.CmdSafetyCheck +import app.aaps.pump.carelevo.common.CarelevoPatch +import app.aaps.pump.carelevo.common.model.Event +import app.aaps.pump.carelevo.common.model.PatchState +import app.aaps.pump.carelevo.common.model.UiState +import app.aaps.pump.carelevo.domain.model.ResponseResult +import app.aaps.pump.carelevo.domain.model.bt.SafetyCheckResult +import app.aaps.pump.carelevo.domain.model.bt.SafetyCheckResultModel +import app.aaps.pump.carelevo.domain.model.patch.CarelevoPatchInfoDomainModel +import app.aaps.pump.carelevo.domain.model.result.ResultSuccess +import app.aaps.pump.carelevo.domain.type.SafetyProgress +import app.aaps.pump.carelevo.domain.usecase.CarelevoUseCaseResponse +import app.aaps.pump.carelevo.domain.usecase.patch.CarelevoPatchForceDiscardUseCase +import app.aaps.pump.carelevo.presentation.model.CarelevoConnectSafetyCheckEvent +import app.aaps.pump.carelevo.presentation.model.CarelevoOverviewEvent +import com.google.common.truth.Truth.assertThat +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.plugins.RxJavaPlugins +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.schedulers.TestScheduler +import io.reactivex.rxjava3.subjects.BehaviorSubject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.Optional +import java.util.concurrent.TimeUnit + +/** + * JVM (non-Robolectric) unit tests for [CarelevoPatchSafetyCheckViewModel]. + * + * The VM needs no Android framework surface — every collaborator is an interface/class that Mockito + * can satisfy, and the only Android-ish dependency is `viewModelScope`, which resolves through + * `Dispatchers.Main.immediate`. Installing [Dispatchers.Unconfined] as Main therefore makes the whole + * VM testable on a plain JVM, and makes every `viewModelScope.launch` (the event emits, the + * `setUiState` hops, the safety-check/discard/priming bodies) run EAGERLY and synchronously inside + * the call that triggered them. That is why the tests can assert StateFlow values straight after + * calling a VM method, with no `runTest`/`advanceUntilIdle`. + * + * Unconfined — NOT `UnconfinedTestDispatcher` — is required: the test dispatcher enters a coroutine + * eagerly but queues every RESUMPTION on its TestCoroutineScheduler, so the progress collector would + * subscribe, park, and never be resumed by an emit before `progressJob.cancel()` runs. Unconfined + * resumes inline on the emitting thread, which is what makes the progress-frame tests deterministic. + * + * `commandQueue.customCommand` is a suspend fun; it is stubbed via [stubCustomCommand] (a + * `runBlocking` wrapper around the normal `whenever`, matching `CarelevoPumpPluginStatusTest`) and + * verified with `verifyBlocking`. + * + * Rx schedulers are stubbed to [Schedulers.trampoline] so the force-discard `Single` runs + * synchronously on the test thread. + * + * The countdown ticker is the one thing the injected [AapsSchedulers] does NOT reach: the VM builds it + * with the 5-arg `Observable.intervalRange`, whose overload hard-wires `Schedulers.computation()` (only + * the `observeOn` hop is injected). It is virtualised instead through + * [RxJavaPlugins.setComputationSchedulerHandler], which swaps in a [TestScheduler] — `Schedulers + * .computation()` routes through that plugin hook on every call, so `intervalRange` picks it up. Ticks + * then fire only on an explicit [TestScheduler.triggerActions]/[TestScheduler.advanceTimeBy], and the + * `observeOn(trampoline)` hop redelivers them in-line on the advancing thread — so a tick is fully + * applied to the StateFlows by the time `advanceTimeBy` returns. + * + * That is also why the ticker tests drive time from INSIDE the `customCommand` answer (see + * [stubCustomCommandEmitting]): that is the only window in which the ticker is alive, since the + * terminal block disposes it before writing the final progress/remainSec. + * + * Timeout arithmetic, for reading the expectations below: the executor emits + * `Progress(durationSeconds + 30)` (a 30 s headroom, `SAFETY_PROGRESS_HEADROOM_SEC`), the frame handler + * seeds `remainSec` with that full value, and `startTicker` then subtracts the headroom again and + * unwinds the REAL duration. So a `Progress(90)` frame means a 60 s bar: tick 30 → 50 %, tick 60 → 100 %. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class CarelevoPatchSafetyCheckViewModelTest { + + @Mock lateinit var aapsSchedulers: AapsSchedulers + @Mock lateinit var aapsLogger: AAPSLogger + @Mock lateinit var carelevoPatch: CarelevoPatch + @Mock lateinit var commandQueue: CommandQueue + @Mock lateinit var activationExecutor: CarelevoActivationExecutor + @Mock lateinit var patchForceDiscardUseCase: CarelevoPatchForceDiscardUseCase + + private lateinit var sut: CarelevoPatchSafetyCheckViewModel + private lateinit var collectorScope: CoroutineScope + + /** + * The one flow the SUT collects — stubbed once, before the VM is built, so tests never re-stub + * `safetyProgress`. Tests drive the Progress branch by emitting into this instance. + */ + private val progressFlow = MutableSharedFlow(extraBufferCapacity = 16) + + private val address = "aa:bb:cc:dd:ee:ff" + + /** Virtual clock for the countdown ticker — see class KDoc. Fresh per test. */ + private lateinit var testScheduler: TestScheduler + + @BeforeEach + fun setUp() { + Dispatchers.setMain(Dispatchers.Unconfined) + collectorScope = CoroutineScope(Dispatchers.Unconfined) + // Virtualise the ticker's hard-wired computation scheduler. This also pins the tests that do + // NOT drive time: with a TestScheduler that is never advanced, intervalRange cannot fire a + // stray tick, so a seeded bar stays exactly as the Progress frame left it. + testScheduler = TestScheduler() + RxJavaPlugins.setComputationSchedulerHandler { testScheduler } + // The force-discard Single ends in a 1-arg subscribe(onSuccess) with no onError, so a failing + // Single routes an OnErrorNotImplementedException to the global Rx handler. Swallow it: the + // doOnError side effects are what the test asserts. + RxJavaPlugins.setErrorHandler { } + + whenever(aapsSchedulers.main).thenReturn(Schedulers.trampoline()) + whenever(aapsSchedulers.io).thenReturn(Schedulers.trampoline()) + // Default: a progress stream that never emits, so startSafetyCheck's collector parks and no + // ticker is started — keeps the non-ticker tests fully deterministic. Overridden where the + // Progress branch is under test. + whenever(activationExecutor.safetyProgress).thenReturn(progressFlow) + + sut = CarelevoPatchSafetyCheckViewModel( + aapsSchedulers = aapsSchedulers, + aapsLogger = aapsLogger, + carelevoPatch = carelevoPatch, + commandQueue = commandQueue, + activationExecutor = activationExecutor, + patchForceDiscardUseCase = patchForceDiscardUseCase + ) + } + + @AfterEach + fun tearDown() { + collectorScope.cancel() + RxJavaPlugins.reset() + Dispatchers.resetMain() + } + + // ---- helpers ------------------------------------------------------------------------------ + + /** Build the result mock BEFORE it is handed to a `thenReturn` (avoids UnfinishedStubbingException). */ + private fun enactResult(success: Boolean): PumpEnactResult = mock().also { + whenever(it.success).thenReturn(success) + } + + /** `customCommand` is suspend — record the stub from inside a coroutine. */ + private fun stubCustomCommand(result: PumpEnactResult) = runBlocking { + whenever(commandQueue.customCommand(any())).thenReturn(result) + } + + /** Stub `customCommand` so it emits one 60 s Progress frame while "running", then returns [result]. */ + private fun stubCustomCommandEmittingProgress( + result: PumpEnactResult, + onSeeded: () -> Unit = {} + ) = stubCustomCommandEmitting(listOf(SafetyProgress.Progress(60L)), result, onSeeded) + + /** + * Stub `customCommand` so it emits [frames] one at a time while "running", then invokes [onSeeded] + * and returns [result]. + * + * The [yield]s are load-bearing, because `startSafetyCheck` launches its progress collector and + * only THEN awaits `customCommand` — a stub that returns instantly is cancelled before the + * collector ever runs. The leading yield lets the collector get scheduled and subscribe (without it + * subscriptionCount stays 0 and the replay-0 SharedFlow drops the emit); the per-frame yield lets + * the collector's dispatched resumption actually process that frame before the next one (or the + * answer returning, and the terminal block cancelling it). Together they reproduce the real queue + * round-trip's suspension. + * + * [onSeeded] runs at the one moment the ticker is both armed and not yet disposed, on the test + * thread — so it is where the ticker tests advance [testScheduler] and sample the StateFlows. + * Keep it assertion-free: it executes inside the VM's coroutine, so a thrown AssertionError would + * surface as an unrelated coroutine failure rather than a clean test failure. Capture into a var + * and assert after [CarelevoPatchSafetyCheckViewModel.startSafetyCheck] returns. + */ + private fun stubCustomCommandEmitting( + frames: List, + result: PumpEnactResult, + onSeeded: () -> Unit = {} + ) = runBlocking { + whenever(commandQueue.customCommand(any())).doSuspendableAnswer { + yield() + frames.forEach { + progressFlow.emit(it) + yield() + } + onSeeded() + result + } + } + + /** Collect one-shot events from the moment of the call, so BOTH emits of a two-event flow land. */ + private fun collectEvents(): MutableList { + val events = mutableListOf() + collectorScope.launch { sut.event.collect { events += it } } + return events + } + + private fun collectProgress(): MutableList { + val values = mutableListOf() + collectorScope.launch { sut.progress.collect { values += it } } + return values + } + + private fun collectRemainSec(): MutableList { + val values = mutableListOf() + collectorScope.launch { sut.remainSec.collect { values += it } } + return values + } + + private fun givenPatchState(state: PatchState?) { + val optional: Optional = if (state == null) Optional.empty() else Optional.of(state) + whenever(carelevoPatch.patchState).thenReturn(BehaviorSubject.createDefault(optional)) + } + + private fun givenPatchInfo(info: CarelevoPatchInfoDomainModel?) { + val optional: Optional = if (info == null) Optional.empty() else Optional.of(info) + whenever(carelevoPatch.patchInfo).thenReturn(BehaviorSubject.createDefault(optional)) + } + + private fun patchInfo(checkSafety: Boolean?) = + CarelevoPatchInfoDomainModel(address = address, checkSafety = checkSafety) + + // ---- defaults / isCreated ----------------------------------------------------------------- + + @Test + fun `initial state is idle with no progress`() { + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + assertThat(sut.progress.value).isNull() + assertThat(sut.remainSec.value).isNull() + assertThat(sut.isCreated).isFalse() + } + + @Test + fun `setIsCreated toggles the created latch`() { + sut.setIsCreated(true) + assertThat(sut.isCreated).isTrue() + + sut.setIsCreated(false) + assertThat(sut.isCreated).isFalse() + } + + // ---- triggerEvent / generateEventType ----------------------------------------------------- + + @Test + fun `triggerEvent forwards a known safety-check event unchanged`() { + val events = collectEvents() + + sut.triggerEvent(CarelevoConnectSafetyCheckEvent.ShowMessageCarelevoIsNotConnected) + + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.ShowMessageCarelevoIsNotConnected) + } + + @Test + fun `triggerEvent maps an unmapped safety-check event to NoAction`() { + // NoAction is the only CarelevoConnectSafetyCheckEvent subtype generateEventType does not + // enumerate, so it falls into the else branch (which itself returns NoAction). + val events = collectEvents() + + sut.triggerEvent(CarelevoConnectSafetyCheckEvent.NoAction) + + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.NoAction) + } + + @Test + fun `triggerEvent ignores events from another hierarchy`() { + val events = collectEvents() + + sut.triggerEvent(CarelevoOverviewEvent.NoAction) + + assertThat(events).isEmpty() + } + + // ---- startSafetyCheck --------------------------------------------------------------------- + + @Test + fun `startSafetyCheck is refused when bluetooth is off`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(false) + val events = collectEvents() + + sut.startSafetyCheck() + + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.ShowMessageBluetoothNotEnabled) + assertThat(sut.progress.value).isNull() + verifyBlocking(commandQueue, never()) { customCommand(any()) } + } + + @Test + fun `startSafetyCheck completes with a full progress bar when the queue reports success`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + stubCustomCommand(enactResult(true)) + val events = collectEvents() + + sut.startSafetyCheck() + + assertThat(sut.progress.value).isEqualTo(100) + assertThat(sut.remainSec.value).isEqualTo(0L) + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.SafetyCheckProgress) + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.SafetyCheckComplete) + } + + @Test + fun `startSafetyCheck routes a CmdSafetyCheck through the command queue`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + stubCustomCommand(enactResult(true)) + + sut.startSafetyCheck() + + verifyBlocking(commandQueue) { customCommand(any()) } + } + + @Test + fun `startSafetyCheck fails without completing the progress bar when the queue reports failure`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + stubCustomCommand(enactResult(false)) + val events = collectEvents() + + sut.startSafetyCheck() + + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.SafetyCheckFailed) + assertThat(events).doesNotContain(CarelevoConnectSafetyCheckEvent.SafetyCheckComplete) + // The failure branch must not fake a finished bar. + assertThat(sut.progress.value).isNull() + assertThat(sut.remainSec.value).isNull() + } + + @Test + fun `a safety-check progress frame seeds the countdown before the terminal success`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + val ok = enactResult(true) + // progress/remainSec are CONFLATED StateFlows, so the seeded 0/60 is legitimately collapsed by + // the terminal 100/0 that follows it — a collector can't reliably observe an intermediate that + // is immediately overwritten. Sample the seed IN FLIGHT instead (inside the command answer, + // after the frame has been processed but before the terminal block runs). That asserts the + // ordering the UI depends on: the bar is seeded while the check runs, then completed. + var seededProgress: Int? = null + var seededRemain: Long? = null + stubCustomCommandEmittingProgress(ok) { + seededProgress = sut.progress.value + seededRemain = sut.remainSec.value + } + val events = collectEvents() + + sut.startSafetyCheck() + + // Progress frame → bar reset to 0 and the countdown seeded with the frame's timeout... + assertThat(seededProgress).isEqualTo(0) + assertThat(seededRemain).isEqualTo(60L) + // ...then the terminal success snaps it to a finished bar (the ticker is disposed first, so + // no late tick can move these back). + assertThat(sut.progress.value).isEqualTo(100) + assertThat(sut.remainSec.value).isEqualTo(0L) + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.SafetyCheckComplete) + } + + @Test + fun `a safety-check progress frame is followed by a failure event when the queue rejects`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + val failed = enactResult(false) + stubCustomCommandEmittingProgress(failed) + val progressValues = collectProgress() + val events = collectEvents() + + sut.startSafetyCheck() + + // The frame really did seed the bar (0), and the rejection must not then complete it. + assertThat(progressValues).contains(0) + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.SafetyCheckFailed) + assertThat(sut.progress.value).isNotEqualTo(100) + } + + // ---- startSafetyCheck: the countdown ticker ------------------------------------------------- + + @Test + fun `the first tick re-bases the countdown from the padded timeout onto the real duration`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + var seededRemain: Long? = null + var tickZero: Pair? = null + // Progress(90) = a 60 s check + the executor's 30 s headroom. + stubCustomCommandEmitting(listOf(SafetyProgress.Progress(90L)), enactResult(true)) { + seededRemain = sut.remainSec.value + testScheduler.triggerActions() + tickZero = sut.progress.value to sut.remainSec.value + } + + sut.startSafetyCheck() + + // The frame handler seeds the FULL padded timeout... + assertThat(seededRemain).isEqualTo(90L) + // ...and the ticker immediately re-bases it onto the 60 s the check actually takes, so the + // countdown the user reads is the duration, not the duration + slack. + assertThat(tickZero).isEqualTo(0 to 60L) + } + + @Test + fun `the ticker advances the bar proportionally as the check runs`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + var halfway: Pair? = null + var finished: Pair? = null + stubCustomCommandEmitting(listOf(SafetyProgress.Progress(90L)), enactResult(true)) { + testScheduler.triggerActions() + testScheduler.advanceTimeBy(30, TimeUnit.SECONDS) + halfway = sut.progress.value to sut.remainSec.value + testScheduler.advanceTimeBy(30, TimeUnit.SECONDS) + finished = sut.progress.value to sut.remainSec.value + } + + sut.startSafetyCheck() + + // 30 s into a 60 s window. + assertThat(halfway).isEqualTo(50 to 30L) + // The ticker reaches a full bar on its own, before the queue result arrives. + assertThat(finished).isEqualTo(100 to 0L) + } + + @Test + fun `the ticker stops at a full bar and never overshoots its window`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + var overrun: Pair? = null + stubCustomCommandEmitting(listOf(SafetyProgress.Progress(90L)), enactResult(true)) { + // Run far past the 60 s window: the emission count bounds the stream, and the percent / + // remain guards clamp it, so a slow check cannot drive the bar past 100 % or the + // countdown below zero. + testScheduler.advanceTimeBy(5, TimeUnit.MINUTES) + overrun = sut.progress.value to sut.remainSec.value + } + + sut.startSafetyCheck() + + assertThat(overrun).isEqualTo(100 to 0L) + } + + @Test + fun `the progress bar never moves backwards while ticking`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + // Sampled synchronously inside the ticker window rather than via collectProgress(): progress is + // a CONFLATED StateFlow, and every write here happens nested inside the VM's coroutine, so an + // Unconfined collector's resumptions only drain once the outermost coroutine unwinds — by then + // the value is already the terminal 100 and the whole history has been conflated away. Sampling + // the value at each advance is the only way to observe the bar actually moving. + val bar = mutableListOf() + stubCustomCommandEmitting(listOf(SafetyProgress.Progress(90L)), enactResult(true)) { + testScheduler.triggerActions() + sut.progress.value?.let(bar::add) // seeded 0 + repeat(6) { + testScheduler.advanceTimeBy(10, TimeUnit.SECONDS) // 60 s bar, sampled every 10 s + sut.progress.value?.let(bar::add) + } + } + + sut.startSafetyCheck() + + sut.progress.value?.let(bar::add) // terminal + // Every value the UI saw, from the seeded 0 through the ticks to the terminal 100, is + // monotonic — a progress bar that jumps backwards reads as a stalled/restarted check. + assertThat(bar).isInOrder() + assertThat(bar.first()).isEqualTo(0) + assertThat(bar.last()).isEqualTo(100) + // ...and it genuinely moved through the middle rather than snapping 0 → 100. + assertThat(bar.any { it in 1..99 }).isTrue() + } + + @Test + fun `a second progress frame re-arms the countdown and abandons the first ticker`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + val ok = enactResult(true) + var afterFirstWindow: Pair? = null + var afterReArm: Pair? = null + runBlocking { + whenever(commandQueue.customCommand(any())).doSuspendableAnswer { + yield() + progressFlow.emit(SafetyProgress.Progress(90L)) // 60 s window + yield() + testScheduler.triggerActions() + testScheduler.advanceTimeBy(30, TimeUnit.SECONDS) + afterFirstWindow = sut.progress.value to sut.remainSec.value + progressFlow.emit(SafetyProgress.Progress(60L)) // re-armed onto a 30 s window + yield() + testScheduler.triggerActions() + testScheduler.advanceTimeBy(5, TimeUnit.SECONDS) + afterReArm = sut.progress.value to sut.remainSec.value + ok + } + } + + sut.startSafetyCheck() + + assertThat(afterFirstWindow).isEqualTo(50 to 30L) + // 5 s into the new 30 s window: the bar was reset and is climbing again on the new base. + // This is what proves the first ticker was disposed rather than left running: a live one + // would be 35 s into its own window by now and, through the monotonic max, would have + // dragged the bar up to 58 % instead of 16 %. + assertThat(afterReArm).isEqualTo(16 to 25L) + } + + @Test + fun `terminal safety-check frames do not arm the countdown`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + var duringCheck: Pair? = null + val terminalFrames = listOf( + SafetyProgress.Success(SafetyCheckResultModel(SafetyCheckResult.SUCCESS, 30, 60)), + SafetyProgress.Error(IllegalStateException("safety check failed")) + ) + stubCustomCommandEmitting(terminalFrames, enactResult(true)) { + testScheduler.advanceTimeBy(60, TimeUnit.SECONDS) + duringCheck = sut.progress.value to sut.remainSec.value + } + + sut.startSafetyCheck() + + // Only Progress frames drive the bar; Success/Error are the executor's own bookkeeping and + // must leave the countdown untouched (no ticker was ever started, hence advancing does + // nothing). + assertThat(duringCheck).isEqualTo(null to null) + // The queue result, not the frames, is what completes the check. + assertThat(sut.progress.value).isEqualTo(100) + } + + @Test + fun `the terminal success snaps a half-run bar to complete and stops the ticker`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + stubCustomCommandEmitting(listOf(SafetyProgress.Progress(90L)), enactResult(true)) { + testScheduler.triggerActions() + testScheduler.advanceTimeBy(30, TimeUnit.SECONDS) // bar sits at 50 % + } + + sut.startSafetyCheck() + + // A check that answers early jumps the bar straight to done rather than letting it crawl. + assertThat(sut.progress.value).isEqualTo(100) + assertThat(sut.remainSec.value).isEqualTo(0L) + + // The ticker was disposed before those writes, so leftover virtual time cannot resurrect the + // countdown. (A live ticker would put remainSec back to 20 here — the bar itself is masked by + // the monotonic max, so remainSec is what actually catches the leak.) + testScheduler.advanceTimeBy(10, TimeUnit.SECONDS) + assertThat(sut.progress.value).isEqualTo(100) + assertThat(sut.remainSec.value).isEqualTo(0L) + } + + @Test + fun `a rejected check leaves the bar where the ticker stopped instead of completing it`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + stubCustomCommandEmitting(listOf(SafetyProgress.Progress(90L)), enactResult(false)) { + testScheduler.triggerActions() + testScheduler.advanceTimeBy(30, TimeUnit.SECONDS) + } + val events = collectEvents() + + sut.startSafetyCheck() + + // The failure path must not fake a finished bar — it freezes at the last tick. + assertThat(sut.progress.value).isEqualTo(50) + assertThat(sut.remainSec.value).isEqualTo(30L) + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.SafetyCheckFailed) + assertThat(events).doesNotContain(CarelevoConnectSafetyCheckEvent.SafetyCheckComplete) + } + + @Test + fun `onSafetyCheckComplete finishes the bar and signals completion`() { + val events = collectEvents() + + sut.onSafetyCheckComplete() + + assertThat(sut.progress.value).isEqualTo(100) + assertThat(sut.remainSec.value).isEqualTo(0L) + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.SafetyCheckComplete) + } + + // ---- startDiscardProcess: gating ---------------------------------------------------------- + + @Test + fun `startDiscardProcess short-circuits when the patch was never booted`() { + givenPatchState(PatchState.NotConnectedNotBooting) + val events = collectEvents() + + sut.startDiscardProcess() + + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.DiscardComplete) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verifyBlocking(commandQueue, never()) { customCommand(any()) } + } + + @Test + fun `startDiscardProcess short-circuits when there is no patch state at all`() { + givenPatchState(null) + val events = collectEvents() + + sut.startDiscardProcess() + + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.DiscardComplete) + verifyBlocking(commandQueue, never()) { customCommand(any()) } + } + + @Test + fun `startDiscardProcess completes through the queue when the patch is reachable`() { + givenPatchState(PatchState.ConnectedBooted) + stubCustomCommand(enactResult(true)) + val events = collectEvents() + + sut.startDiscardProcess() + + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.DiscardComplete) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verifyBlocking(commandQueue) { customCommand(any()) } + // The BLE teardown lives inside CmdDiscard on the queue thread, not in the VM. + verify(carelevoPatch, never()).discardTeardown() + } + + @Test + fun `startDiscardProcess runs on the connected-not-booted branch too`() { + givenPatchState(PatchState.ConnectedNoBooted) + stubCustomCommand(enactResult(true)) + val events = collectEvents() + + sut.startDiscardProcess() + + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.DiscardComplete) + verifyBlocking(commandQueue) { customCommand(any()) } + } + + // ---- startDiscardProcess → force-discard fallback ------------------------------------------ + + @Test + fun `a rejected queue discard falls back to force-discard and tears the patch down`() { + givenPatchState(PatchState.NotConnectedBooted) + stubCustomCommand(enactResult(false)) + val success: ResponseResult = ResponseResult.Success(ResultSuccess) + whenever(patchForceDiscardUseCase.execute()).thenReturn(Single.just(success)) + val events = collectEvents() + + sut.startDiscardProcess() + + verify(carelevoPatch).discardTeardown() + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.DiscardComplete) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } + + @Test + fun `a force-discard that errors leaves the patch intact and reports failure`() { + givenPatchState(PatchState.ConnectedBooted) + stubCustomCommand(enactResult(false)) + val error: ResponseResult = ResponseResult.Error(IllegalStateException("delete patch info is failed")) + whenever(patchForceDiscardUseCase.execute()).thenReturn(Single.just(error)) + val events = collectEvents() + + sut.startDiscardProcess() + + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.DiscardFailed) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verify(carelevoPatch, never()).discardTeardown() + } + + @Test + fun `a force-discard that returns Failure reports failure`() { + givenPatchState(PatchState.ConnectedBooted) + stubCustomCommand(enactResult(false)) + val failure: ResponseResult = ResponseResult.Failure("nope") + whenever(patchForceDiscardUseCase.execute()).thenReturn(Single.just(failure)) + val events = collectEvents() + + sut.startDiscardProcess() + + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.DiscardFailed) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verify(carelevoPatch, never()).discardTeardown() + } + + @Test + fun `a force-discard whose stream throws reports failure through doOnError`() { + givenPatchState(PatchState.ConnectedBooted) + stubCustomCommand(enactResult(false)) + whenever(patchForceDiscardUseCase.execute()) + .thenReturn(Single.error(RuntimeException("db down"))) + val events = collectEvents() + + sut.startDiscardProcess() + + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.DiscardFailed) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verify(carelevoPatch, never()).discardTeardown() + } + + // ---- retryAdditionalPriming --------------------------------------------------------------- + + @Test + fun `retryAdditionalPriming is refused when bluetooth is off`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(false) + val events = collectEvents() + + sut.retryAdditionalPriming() + + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.ShowMessageBluetoothNotEnabled) + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + verifyBlocking(commandQueue, never()) { customCommand(any()) } + } + + @Test + fun `retryAdditionalPriming returns to idle without feedback on success`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + stubCustomCommand(enactResult(true)) + val events = collectEvents() + + sut.retryAdditionalPriming() + + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + assertThat(events).doesNotContain(CarelevoConnectSafetyCheckEvent.DiscardFailed) + verifyBlocking(commandQueue) { customCommand(any()) } + } + + @Test + fun `retryAdditionalPriming surfaces feedback and returns to idle on failure`() { + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + stubCustomCommand(enactResult(false)) + val events = collectEvents() + + sut.retryAdditionalPriming() + + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + assertThat(events).contains(CarelevoConnectSafetyCheckEvent.DiscardFailed) + } + + // ---- isSafetyCheckPassed ------------------------------------------------------------------ + + @Test + fun `isSafetyCheckPassed is true only when the patch recorded a passed check`() { + givenPatchInfo(patchInfo(checkSafety = true)) + + assertThat(sut.isSafetyCheckPassed()).isTrue() + } + + @Test + fun `isSafetyCheckPassed is false when the patch recorded a failed check`() { + givenPatchInfo(patchInfo(checkSafety = false)) + + assertThat(sut.isSafetyCheckPassed()).isFalse() + } + + @Test + fun `isSafetyCheckPassed is false when the check was never recorded`() { + givenPatchInfo(patchInfo(checkSafety = null)) + + assertThat(sut.isSafetyCheckPassed()).isFalse() + } + + @Test + fun `isSafetyCheckPassed is false when there is no patch info`() { + givenPatchInfo(null) + + assertThat(sut.isSafetyCheckPassed()).isFalse() + } + + // ---- isConnected -------------------------------------------------------------------------- + + @Test + fun `isConnected is false without a paired patch address`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(null) + + assertThat(sut.isConnected()).isFalse() + } + + @Test + fun `isConnected is false when bluetooth is off`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(address) + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(false) + + assertThat(sut.isConnected()).isFalse() + } + + @Test + fun `isConnected is true when a session can be attempted`() { + whenever(carelevoPatch.getPatchInfoAddress()).thenReturn(address) + whenever(carelevoPatch.isBluetoothEnabled()).thenReturn(true) + + assertThat(sut.isConnected()).isTrue() + } + + // ---- onCleared ---------------------------------------------------------------------------- + + @Test + fun `onCleared disposes the force-discard subscription without throwing`() { + // onCleared is protected on ViewModel and the VM is final, so drive it reflectively. + val onCleared = sut.javaClass.getDeclaredMethod("onCleared") + onCleared.isAccessible = true + + onCleared.invoke(sut) + + assertThat(sut.uiState.value).isEqualTo(UiState.Idle) + } +} diff --git a/settings.gradle b/settings.gradle index 86453a00be06..b6507380c774 100644 --- a/settings.gradle +++ b/settings.gradle @@ -44,6 +44,8 @@ include ':pump:omnipod:eros' include ':pump:omnipod:dash' include ':pump:common' include ':pump:rileylink' +include ':pump:carelevo' +include ':pump:carelevo-emulator' include ':pump:virtual' include ':shared:impl' include ':shared:tests'