Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions android/app/src/main/java/com/noop/ui/StressCoreRefresh.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.noop.ui

/** Memoises successful union reads; failed reads remain eligible on the next refresh tick. */
internal class StressCoreRefresh<T : Any> {
private var lastHrFingerprint: String? = null

suspend fun loadIfChanged(fingerprint: String?, load: suspend () -> T): T? {
if (fingerprint != null && fingerprint == lastHrFingerprint) return null
val core = StressLoadCancellation.read(load) ?: return null
lastHrFingerprint = fingerprint
return core
}
}
15 changes: 7 additions & 8 deletions android/app/src/main/java/com/noop/ui/StressScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ fun StressScreen(vm: AppViewModel, onBreathe: () -> Unit = {}) {
// not: with the strap disconnected, or simply quiet, nothing about today's heart rate has moved
// and re-reading produces a result identical to the one already on screen. One indexed count
// and max per source id answers that for the price of neither.
var lastHrFingerprint: String? = null
val refresh = StressCoreRefresh<DaytimeCore>()
lifecycleOwner.lifecycle.repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.STARTED) {
while (true) {
val nowSeconds = System.currentTimeMillis() / 1000L
Expand All @@ -162,19 +162,18 @@ fun StressScreen(vm: AppViewModel, onBreathe: () -> Unit = {}) {
}
// A failed fingerprint reads as "cannot tell", which loads rather than skips: being
// wrong about this costs one pass, being wrong the other way freezes the screen.
if (fingerprint == null || fingerprint != lastHrFingerprint) {
lastHrFingerprint = fingerprint
val personal = NoopPrefs.stressPersonalBaseline(context)
val core = refresh.loadIfChanged(fingerprint) { loadDaytimeCore(vm, personal) }
if (core != null) {
// TWO phases, so the chart is not held behind work it does not use (#2181). The
// scoring pass publishes first and the line can draw; the two optional HRV lenses,
// which live in their own card and include a Lomb-Scargle periodogram over the whole
// day of beats, fill in after. Both phases run on Dispatchers.Default rather than on
// the LaunchedEffect's main thread, which is what made this a frozen screen instead
// of a slow one.
val personal = NoopPrefs.stressPersonalBaseline(context)
val core = runCatching { loadDaytimeCore(vm, personal) }.getOrNull()
daytime = core?.daytime ?: DaytimeStress.Result.EMPTY
daytimeUsesPersonalBaseline = core?.usesPersonalBaseline == true
val beats = core?.rr.orEmpty()
daytime = core.daytime
daytimeUsesPersonalBaseline = core.usesPersonalBaseline
val beats = core.rr
val lenses = if (beats.isEmpty()) null else StressLoadCancellation.read {
withContext(Dispatchers.Default) {
StressIndex.components(beats) to HrvFreqDomain.freqDomain(beats)
Expand Down
98 changes: 98 additions & 0 deletions android/app/src/test/java/com/noop/ui/StressCoreRefreshTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package com.noop.ui

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Test

class StressCoreRefreshTest {
@Test
fun failedFirstReadRetriesUnchangedDataAndThenMemoisesSuccess() = runBlocking {
val refresh = StressCoreRefresh<String>()
val fingerprint = "whoop-active=100:500,my-whoop=0:0"
var attempts = 0
suspend fun load(): String {
attempts++
if (attempts == 1) error("temporary read failure")
return "timeline"
}
assertNull(refresh.loadIfChanged(fingerprint, ::load))
assertEquals("timeline", refresh.loadIfChanged(fingerprint, ::load))
assertNull(refresh.loadIfChanged(fingerprint, ::load))
assertEquals(2, attempts)
}

@Test
fun failedRefreshRetainsDisplayedCoreAndRetriesNewFingerprint() = runBlocking {
val refresh = StressCoreRefresh<String>()
val first = "whoop-active=100:500,my-whoop=0:0"
val next = "whoop-active=101:510,my-whoop=0:0"
var displayed = "loading"
refresh.loadIfChanged(first) { "previous" }?.let { displayed = it }
refresh.loadIfChanged(next) { error("temporary failure") }?.let { displayed = it }
assertEquals("previous", displayed)
refresh.loadIfChanged(next) { "updated" }?.let { displayed = it }
assertEquals("updated", displayed)
}

@Test
fun unknownFingerprintAlwaysReadsAndSuccessfulEmptyCoreIsMemoised() = runBlocking {
val refresh = StressCoreRefresh<List<Int>>()
assertEquals(emptyList<Int>(), refresh.loadIfChanged(null) { emptyList() })
assertEquals(listOf(1), refresh.loadIfChanged(null) { listOf(1) })
val emptyFingerprint = "whoop-active=0:0,my-whoop=0:0"
assertEquals(emptyList<Int>(), refresh.loadIfChanged(emptyFingerprint) { emptyList() })
assertNull(refresh.loadIfChanged(emptyFingerprint) { error("must skip successful empty read") })
}

@Test
fun lifecycleCancellationPropagatesAndDoesNotConsumeFingerprint() = runBlocking {
val refresh = StressCoreRefresh<String>()
val fingerprint = "whoop-active=100:500,my-whoop=0:0"
val cancellation = CancellationException("screen stopped")
try {
refresh.loadIfChanged(fingerprint) { throw cancellation }
fail("cancellation must propagate")
} catch (actual: CancellationException) {
assertSame(cancellation, actual)
}
assertEquals("resumed", refresh.loadIfChanged(fingerprint) { "resumed" })
}

@Test
fun aliasBackfillChangesTheUnionFingerprintAndRetriesWithoutChangingActiveHr() = runBlocking {
val refresh = StressCoreRefresh<String>()
val before = "whoop-active=100:500,whoop-archived=1:400,my-whoop=0:0"
val after = "whoop-active=100:500,whoop-archived=2:400,my-whoop=0:0"
assertEquals("previous", refresh.loadIfChanged(before) { "previous" })
assertNull(refresh.loadIfChanged(after) { error("temporary read failure") })
assertEquals("backfilled", refresh.loadIfChanged(after) { "backfilled" })
assertNull(refresh.loadIfChanged(after) { error("must skip successful union read") })
}

@Test
fun lateCancelledCoreCannotPublishOrConsumeTheUnionFingerprint() = runTest {
val refresh = StressCoreRefresh<String>()
val fingerprint = "whoop-active=100:500,my-whoop=0:0"
val entered = CompletableDeferred<Unit>()
val released = CompletableDeferred<Unit>()
val published = mutableListOf<String>()
val old = launch {
refresh.loadIfChanged(fingerprint) {
withContext(NonCancellable) { entered.complete(Unit); released.await() }
"stale"
}?.let { published += it }
}
entered.await()
old.cancel()
released.complete(Unit)
old.join()
assertEquals(emptyList<String>(), published)
assertEquals("resumed", refresh.loadIfChanged(fingerprint) { "resumed" })
}
}
Loading