mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat(overview): Say how many earbud charges the case still holds
The case percentage alone doesn't answer the question people actually have, which is whether the case can top the earbuds up again. The card now says so in words, below the case row. The reading is treated as the interval it really is: a decile source at 20% on a 4.0-charge case means 0.8 to 1.2 charges, which is not an answer, so the line stays neutral instead of flipping colour as the reading bounces between adjacent frames. Only an interval that lies wholly above or below one full charge is coloured, which removes the need to remember anything between frames. The line is stacked rather than inline: the case row already has four children with only the capsule weighted, so a plural in front of it would collapse the capsule at large font scales and in locales with long plural forms.
This commit is contained in:
@@ -264,6 +264,51 @@ object MockPodDataProvider {
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Dual pods with hand-picked battery levels. The case reads at decile granularity, like the
|
||||
* public advertisement it would come from.
|
||||
*/
|
||||
fun dualPodBatteries(
|
||||
left: Float? = 0.80f,
|
||||
right: Float? = 0.45f,
|
||||
case: Float? = 0.60f,
|
||||
model: PodModel = PodModel.AIRPODS_PRO2,
|
||||
label: String = "My AirPods Pro",
|
||||
): PodDevice = PodDevice(
|
||||
profileId = "preview-dual-batteries",
|
||||
label = label,
|
||||
ble = MockDualBlePodSnapshot(
|
||||
_model = model,
|
||||
_label = label,
|
||||
batteryLeftPodPercent = left,
|
||||
batteryRightPodPercent = right,
|
||||
_batteryCasePercent = case,
|
||||
leftPodIcon = model.leftPodIconRes ?: R.drawable.device_airpods_gen1_left,
|
||||
rightPodIcon = model.rightPodIconRes ?: R.drawable.device_airpods_gen1_right,
|
||||
_caseIcon = model.caseIconRes ?: R.drawable.device_airpods_gen1_case,
|
||||
_address = "AA:BB:CC:DD:EE:FF",
|
||||
),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
/** A single-pod device with a hand-picked battery level. */
|
||||
fun singlePodBattery(
|
||||
percent: Float,
|
||||
model: PodModel = PodModel.AIRPODS_MAX,
|
||||
label: String = "AirPods Max",
|
||||
): PodDevice = PodDevice(
|
||||
profileId = "preview-single-battery",
|
||||
label = label,
|
||||
ble = MockSingleBlePodSnapshot(
|
||||
_model = model,
|
||||
_label = label,
|
||||
batteryHeadsetPercent = percent,
|
||||
_isBeingWorn = true,
|
||||
_address = "AA:BB:CC:DD:EE:FF",
|
||||
),
|
||||
aap = null,
|
||||
)
|
||||
|
||||
fun singlePodMonitored(): PodDevice = PodDevice(
|
||||
profileId = "preview-single",
|
||||
label = "AirPods Max",
|
||||
|
||||
@@ -42,7 +42,10 @@ import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
@@ -61,13 +64,18 @@ import eu.darken.capod.common.SystemTimeSource
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.common.compose.preview.MockPodDataProvider
|
||||
import eu.darken.capod.common.theming.LocalBatteryColors
|
||||
import eu.darken.capod.common.theming.fillColor
|
||||
import eu.darken.capod.common.theming.textColorOrNull
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.batteryCaseReading
|
||||
import eu.darken.capod.monitor.core.battery.BatteryEstimate
|
||||
import eu.darken.capod.monitor.core.battery.BatteryTier
|
||||
import eu.darken.capod.monitor.core.battery.CaseCharges
|
||||
import eu.darken.capod.monitor.core.battery.batteryTier
|
||||
import eu.darken.capod.monitor.core.battery.caseCharges
|
||||
import eu.darken.capod.monitor.core.cachedBatteryFormatted
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
|
||||
@@ -418,69 +426,125 @@ private fun CaseRow(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val tier = batteryTier(device.batteryCase)
|
||||
val charges = caseCharges(device.model.caseSpec, device.batteryCaseReading)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.batteryTierState(tier),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(device.caseIcon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = formatBatteryPercent(context, device.batteryCase),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = tier.textColorOrNull() ?: LocalContentColor.current,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
|
||||
BatteryCapsule(
|
||||
percent = device.batteryCase,
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(8.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
.fillMaxWidth()
|
||||
.batteryTierState(tier),
|
||||
) {
|
||||
when (val caseState = device.caseChargingState
|
||||
?: device.isCaseCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }) {
|
||||
AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_optimized_label),
|
||||
)
|
||||
AapPodState.ChargingState.CHARGING -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_label),
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
Image(
|
||||
painter = painterResource(device.caseIcon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
|
||||
val lidState = device.caseLidState
|
||||
if (lidState == LidState.OPEN || lidState == LidState.CLOSED) {
|
||||
StatusChip(
|
||||
icon = Icons.TwoTone.GridView,
|
||||
label = when (lidState) {
|
||||
LidState.OPEN -> stringResource(R.string.pods_case_status_open_label)
|
||||
LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label)
|
||||
else -> ""
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Text(
|
||||
text = formatBatteryPercent(context, device.batteryCase),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = tier.textColorOrNull() ?: LocalContentColor.current,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
|
||||
BatteryCapsule(
|
||||
percent = device.batteryCase,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(8.dp),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
when (val caseState = device.caseChargingState
|
||||
?: device.isCaseCharging?.let { if (it) AapPodState.ChargingState.CHARGING else null }) {
|
||||
AapPodState.ChargingState.CHARGING_OPTIMIZED -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_optimized_label),
|
||||
)
|
||||
AapPodState.ChargingState.CHARGING -> StatusChip(
|
||||
icon = Icons.TwoTone.BatteryChargingFull,
|
||||
label = stringResource(R.string.pods_charging_label),
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
val lidState = device.caseLidState
|
||||
if (lidState == LidState.OPEN || lidState == LidState.CLOSED) {
|
||||
StatusChip(
|
||||
icon = Icons.TwoTone.GridView,
|
||||
label = when (lidState) {
|
||||
LidState.OPEN -> stringResource(R.string.pods_case_status_open_label)
|
||||
LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label)
|
||||
else -> ""
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (charges != null) {
|
||||
CaseChargesLine(
|
||||
charges = charges,
|
||||
// Aligned under the percentage, past the case icon and its spacer.
|
||||
modifier = Modifier.padding(start = 36.dp, top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacked below the case row rather than inline: that row already carries four children of which
|
||||
* only the capsule is weighted, so an unbounded plural in front of it squeezes the capsule away at
|
||||
* large font scales and in locales with long plural forms.
|
||||
*/
|
||||
@Composable
|
||||
private fun CaseChargesLine(
|
||||
charges: CaseCharges,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val text = when (charges.display) {
|
||||
CaseCharges.Display.EMPTY -> stringResource(R.string.battery_case_charges_empty)
|
||||
CaseCharges.Display.LESS_THAN_ONE -> stringResource(R.string.battery_case_charges_less_than_one)
|
||||
CaseCharges.Display.AT_LEAST -> pluralStringResource(
|
||||
R.plurals.battery_case_charges_at_least,
|
||||
charges.count,
|
||||
charges.count,
|
||||
)
|
||||
CaseCharges.Display.APPROXIMATE -> pluralStringResource(
|
||||
R.plurals.battery_case_charges_approx,
|
||||
charges.count,
|
||||
charges.count,
|
||||
)
|
||||
}
|
||||
// The same visible count can be adequate or not depending on how coarse the reading was, so
|
||||
// the adequacy needs its own announcement rather than riding on the case battery's state.
|
||||
val state = when (charges.adequacy) {
|
||||
CaseCharges.Adequacy.ENOUGH -> stringResource(R.string.battery_case_charges_state_enough_cd)
|
||||
CaseCharges.Adequacy.NOT_ENOUGH -> stringResource(R.string.battery_case_charges_state_not_enough_cd)
|
||||
CaseCharges.Adequacy.UNCERTAIN -> stringResource(R.string.battery_case_charges_state_uncertain_cd)
|
||||
}
|
||||
val color = when (charges.adequacy) {
|
||||
CaseCharges.Adequacy.ENOUGH -> LocalBatteryColors.current.positiveText
|
||||
CaseCharges.Adequacy.NOT_ENOUGH -> LocalBatteryColors.current.warnText
|
||||
CaseCharges.Adequacy.UNCERTAIN -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = color,
|
||||
modifier = modifier.semantics { stateDescription = state },
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardFullPreview() = PreviewWrapper {
|
||||
@@ -532,6 +596,54 @@ private fun DualPodsCardEstimateProvisionalPreview() = PreviewWrapper {
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardWarnPreview() = PreviewWrapper {
|
||||
// Case at 30% of a 4.0-charge spec: even the bottom of the decile band clears a full charge.
|
||||
DualPodsCard(
|
||||
device = MockPodDataProvider.dualPodBatteries(left = 0.22f, right = 0.18f, case = 0.30f),
|
||||
showDebug = false,
|
||||
now = SystemTimeSource.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardCriticalPreview() = PreviewWrapper {
|
||||
// Case at 10%: the whole band stays under a full charge.
|
||||
DualPodsCard(
|
||||
device = MockPodDataProvider.dualPodBatteries(left = 0.10f, right = 0.05f, case = 0.10f),
|
||||
showDebug = false,
|
||||
now = SystemTimeSource.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardCaseChargesUncertainPreview() = PreviewWrapper {
|
||||
// Case at 20%: the decile band straddles a full charge, so the line makes no claim.
|
||||
DualPodsCard(
|
||||
device = MockPodDataProvider.dualPodBatteries(case = 0.20f),
|
||||
showDebug = false,
|
||||
now = SystemTimeSource.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardCaseWithoutSpecPreview() = PreviewWrapper {
|
||||
// PowerBeats Pro has a case, but Apple publishes no figures for it, so there is no line.
|
||||
DualPodsCard(
|
||||
device = MockPodDataProvider.dualPodBatteries(
|
||||
case = 0.60f,
|
||||
model = PodModel.POWERBEATS_PRO,
|
||||
label = "PowerBeats Pro",
|
||||
),
|
||||
showDebug = false,
|
||||
now = SystemTimeSource.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun DualPodsCardMinimalPreview() = PreviewWrapper {
|
||||
|
||||
@@ -375,6 +375,26 @@ private fun SinglePodsCardEstimatePreview() = PreviewWrapper {
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun SinglePodsCardWarnPreview() = PreviewWrapper {
|
||||
SinglePodsCard(
|
||||
device = MockPodDataProvider.singlePodBattery(percent = 0.20f),
|
||||
showDebug = false,
|
||||
now = SystemTimeSource.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun SinglePodsCardCriticalPreview() = PreviewWrapper {
|
||||
SinglePodsCard(
|
||||
device = MockPodDataProvider.singlePodBattery(percent = 0.08f),
|
||||
showDebug = false,
|
||||
now = SystemTimeSource.now(),
|
||||
)
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun SinglePodsCardMinimalPreview() = PreviewWrapper {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package eu.darken.capod.monitor.core.battery
|
||||
|
||||
import eu.darken.capod.monitor.core.BatteryReading
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import kotlin.math.floor
|
||||
|
||||
/** How many more times the case can recharge both earbuds, and how firm that number is. */
|
||||
data class CaseCharges(
|
||||
val count: Int,
|
||||
val display: Display,
|
||||
val adequacy: Adequacy,
|
||||
) {
|
||||
enum class Display {
|
||||
/** Nothing left to give — distinct from [LESS_THAN_ONE], which still implies some charge. */
|
||||
EMPTY,
|
||||
LESS_THAN_ONE,
|
||||
AT_LEAST,
|
||||
APPROXIMATE,
|
||||
}
|
||||
|
||||
enum class Adequacy { ENOUGH, NOT_ENOUGH, UNCERTAIN }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a truncating [reading] as the interval `[percent, percent + resolution)` and scales it by
|
||||
* the spec, so a decile source at 20% asks "is 4.0 x 0.20 .. 4.0 x 0.30 above one charge?" rather
|
||||
* than pretending 0.8 is exact. Only an interval that lies wholly on one side of a full charge
|
||||
* makes a claim; anything straddling it is [CaseCharges.Adequacy.UNCERTAIN]. That also absorbs the
|
||||
* bounce between adjacent readings, so no state has to be remembered between frames.
|
||||
*
|
||||
* A spec that is itself a lower bound has no upper end, so it can never claim "not enough".
|
||||
*
|
||||
* Null when the model publishes no case figures or the reading is missing or nonsense — the caller
|
||||
* shows no line at all rather than a neutral one.
|
||||
*/
|
||||
fun caseCharges(spec: PodModel.CaseSpec?, reading: BatteryReading?): CaseCharges? {
|
||||
if (spec == null || !spec.fullPairRecharges.isFinite() || spec.fullPairRecharges <= 0f) return null
|
||||
if (reading == null || !reading.percent.isFinite() || reading.percent < 0f) return null
|
||||
|
||||
val resolution = if (reading.resolution.isFinite() && reading.resolution > 0f) reading.resolution else 0f
|
||||
val lowest = spec.fullPairRecharges * reading.percent
|
||||
val highest = spec.fullPairRecharges * (reading.percent + resolution)
|
||||
|
||||
val adequacy = when {
|
||||
lowest >= 1f -> CaseCharges.Adequacy.ENOUGH
|
||||
spec.isLowerBound -> CaseCharges.Adequacy.UNCERTAIN
|
||||
highest < 1f -> CaseCharges.Adequacy.NOT_ENOUGH
|
||||
else -> CaseCharges.Adequacy.UNCERTAIN
|
||||
}
|
||||
|
||||
val count = floor(lowest).toInt()
|
||||
val display = when {
|
||||
reading.percent == 0f -> CaseCharges.Display.EMPTY
|
||||
count < 1 -> CaseCharges.Display.LESS_THAN_ONE
|
||||
spec.isLowerBound -> CaseCharges.Display.AT_LEAST
|
||||
else -> CaseCharges.Display.APPROXIMATE
|
||||
}
|
||||
|
||||
return CaseCharges(count = count, display = display, adequacy = adequacy)
|
||||
}
|
||||
@@ -276,6 +276,20 @@
|
||||
<string name="battery_unavailable_label">Battery unavailable</string>
|
||||
<string name="battery_state_low_cd">Battery low</string>
|
||||
<string name="battery_state_critical_cd">Battery critically low</string>
|
||||
<!-- Case charges: how often the case can still fully recharge BOTH earbuds. Not charge cycles, and not a statement that anything is charging right now. -->
|
||||
<plurals name="battery_case_charges_approx">
|
||||
<item quantity="one">~%d more full charge for both earbuds</item>
|
||||
<item quantity="other">~%d more full charges for both earbuds</item>
|
||||
</plurals>
|
||||
<plurals name="battery_case_charges_at_least">
|
||||
<item quantity="one">At least %d more full charge for both earbuds</item>
|
||||
<item quantity="other">At least %d more full charges for both earbuds</item>
|
||||
</plurals>
|
||||
<string name="battery_case_charges_less_than_one">Less than one more full charge for both earbuds</string>
|
||||
<string name="battery_case_charges_empty">Case is empty, no charges left for your earbuds</string>
|
||||
<string name="battery_case_charges_state_enough_cd">Enough for a full charge</string>
|
||||
<string name="battery_case_charges_state_not_enough_cd">Not enough for a full charge</string>
|
||||
<string name="battery_case_charges_state_uncertain_cd">May not be enough for a full charge</string>
|
||||
<string name="pods_case_status_open_label">Open</string>
|
||||
<string name="pods_case_status_closed_label">Closed</string>
|
||||
<string name="pods_connection_state_disconnected_label">Not connected to a device</string>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package eu.darken.capod.monitor.core.battery
|
||||
|
||||
import eu.darken.capod.monitor.core.BatteryReading
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
|
||||
class CaseChargesTest : BaseTest() {
|
||||
|
||||
private val exact = PodModel.CaseSpec(fullPairRecharges = 4.0f)
|
||||
private val lowerBound = PodModel.CaseSpec(fullPairRecharges = 3.8f, isLowerBound = true)
|
||||
|
||||
private val decile = 0.10f
|
||||
private val percent = 0.01f
|
||||
|
||||
private fun charges(spec: PodModel.CaseSpec?, percent: Float, resolution: Float) =
|
||||
caseCharges(spec, BatteryReading(percent, resolution))
|
||||
|
||||
@Test
|
||||
fun `no spec means no line`() {
|
||||
caseCharges(null, BatteryReading(0.5f, percent)).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no reading means no line`() {
|
||||
caseCharges(exact, null).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unusable spec is treated as absent`() {
|
||||
charges(PodModel.CaseSpec(fullPairRecharges = 0f), 0.5f, percent).shouldBeNull()
|
||||
charges(PodModel.CaseSpec(fullPairRecharges = -1f), 0.5f, percent).shouldBeNull()
|
||||
charges(PodModel.CaseSpec(fullPairRecharges = Float.NaN), 0.5f, percent).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unusable reading is treated as absent`() {
|
||||
charges(exact, -1f, percent).shouldBeNull()
|
||||
charges(exact, Float.NaN, percent).shouldBeNull()
|
||||
charges(exact, Float.POSITIVE_INFINITY, percent).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the count comes from the spec times the reading`() {
|
||||
charges(exact, 1.0f, percent)!!.count shouldBe 4
|
||||
charges(exact, 0.75f, percent)!!.count shouldBe 3
|
||||
charges(exact, 0.50f, percent)!!.count shouldBe 2
|
||||
charges(lowerBound, 1.0f, percent)!!.count shouldBe 3
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a whole interval above one charge is enough`() {
|
||||
// 4.0 x 0.25 = 1.0 exactly, so even the low end clears a full charge
|
||||
charges(exact, 0.25f, decile)!!.adequacy shouldBe CaseCharges.Adequacy.ENOUGH
|
||||
charges(exact, 0.60f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.ENOUGH
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a whole interval below one charge is not enough`() {
|
||||
// 4.0 x (0.10 + 0.10) = 0.8, still short of a full charge at the top of the interval
|
||||
charges(exact, 0.10f, decile)!!.adequacy shouldBe CaseCharges.Adequacy.NOT_ENOUGH
|
||||
charges(exact, 0.20f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.NOT_ENOUGH
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an interval straddling one charge claims nothing`() {
|
||||
// 4.0 x 0.20 = 0.8, 4.0 x 0.30 = 1.2
|
||||
charges(exact, 0.20f, decile)!!.adequacy shouldBe CaseCharges.Adequacy.UNCERTAIN
|
||||
// the same reading at a finer resolution does resolve
|
||||
charges(exact, 0.20f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.NOT_ENOUGH
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lower bound spec never claims not enough`() {
|
||||
// 3.8 x 0.25 = 0.95 — a confident "not enough" for a case that may well hold more
|
||||
charges(lowerBound, 0.25f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.UNCERTAIN
|
||||
charges(lowerBound, 0.01f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.UNCERTAIN
|
||||
charges(lowerBound, 0.10f, decile)!!.adequacy shouldBe CaseCharges.Adequacy.UNCERTAIN
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lower bound spec can still claim enough`() {
|
||||
charges(lowerBound, 0.30f, percent)!!.adequacy shouldBe CaseCharges.Adequacy.ENOUGH
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an exact spec renders as an approximation`() {
|
||||
charges(exact, 0.50f, percent)!!.display shouldBe CaseCharges.Display.APPROXIMATE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lower bound spec renders as a floor`() {
|
||||
charges(lowerBound, 0.50f, percent)!!.display shouldBe CaseCharges.Display.AT_LEAST
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `below a full charge renders as less than one`() {
|
||||
charges(exact, 0.20f, percent)!!.display shouldBe CaseCharges.Display.LESS_THAN_ONE
|
||||
charges(lowerBound, 0.20f, percent)!!.display shouldBe CaseCharges.Display.LESS_THAN_ONE
|
||||
charges(exact, 0.01f, percent)!!.display shouldBe CaseCharges.Display.LESS_THAN_ONE
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty case renders as empty`() {
|
||||
charges(exact, 0f, percent)!!.display shouldBe CaseCharges.Display.EMPTY
|
||||
charges(lowerBound, 0f, decile)!!.display shouldBe CaseCharges.Display.EMPTY
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user