Harden WebView nav + add test suite + fix lint for production
Build APK / build (push) Successful in 1m29s
Build APK / build (push) Successful in 1m29s
Security: - LoginActivity WebView now enforces a host allow-list in shouldOverrideUrlLoading: only claude.ai + required SSO/CDN hosts (Google, Apple, Cloudflare, gstatic, recaptcha) can navigate; everything else is blocked. market://intent:// still blocked; about:/data: allowed. Device-verified: claude.ai login + Cloudflare challenge still load. Tests (33, pure-JVM JUnit4, no device needed): - Extracted shouldRecordHistory() pure throttle decision (regression guard for the empty-history-chart bug) + HistoryThrottleTest. - UsageDataTest (mergedWith last-good/partial-union, computed props), PaceCalcTest, PeakHoursTest. - Added junit:junit:4.13.2 as testImplementation only. Build quality: - widget_layout.xml: suppress false-positive UseAppTint lint on the widget refresh button (app:tint doesn't work in RemoteViews; android:tint is correct here) so lintDebug is clean. Verified locally: 33 unit tests pass, lintDebug 0 errors, signed assembleRelease OK (apksigner verified, signer identity unchanged), emulator smoke test launches + renders without crash.
This commit is contained in:
@@ -101,4 +101,7 @@ dependencies {
|
|||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||||
|
|
||||||
|
// Unit tests (pure JVM — no Robolectric, no Android framework)
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,9 +85,19 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
webViewClient = object : WebViewClient() {
|
webViewClient = object : WebViewClient() {
|
||||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||||
val url = request.url.toString()
|
val uri = request.url
|
||||||
|
val url = uri.toString()
|
||||||
|
// Keep blocking app-redirect schemes outright.
|
||||||
if (url.startsWith("market://") || url.startsWith("intent://")) return true
|
if (url.startsWith("market://") || url.startsWith("intent://")) return true
|
||||||
return false
|
val scheme = uri.scheme?.lowercase()
|
||||||
|
// about:blank and data: URIs are used by the login page itself (e.g. blank
|
||||||
|
// bootstrap frames, inline images). They have no host to allow-list, so let them load.
|
||||||
|
if (scheme == "about" || scheme == "data") return false
|
||||||
|
// Host ALLOW-LIST: only navigate to claude.ai and its SSO/CDN providers.
|
||||||
|
// OAuth runs as full-page redirects in THIS WebView (multiple windows disabled,
|
||||||
|
// JS-opened windows disabled), so the list MUST include the Google/Apple SSO
|
||||||
|
// and Cloudflare challenge hosts or sign-in breaks. Anything else is blocked.
|
||||||
|
return !isHostAllowed(uri.host)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageStarted(view: WebView, url: String, favicon: android.graphics.Bitmap?) {
|
override fun onPageStarted(view: WebView, url: String, favicon: android.graphics.Bitmap?) {
|
||||||
@@ -169,4 +179,35 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
binding.webView.destroy()
|
binding.webView.destroy()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if [host] belongs to an allowed domain (exact match or a subdomain via dot-suffix).
|
||||||
|
* A null/blank host is never allowed. accounts.google.com, challenges.cloudflare.com, etc. are
|
||||||
|
* covered by their parent-domain suffixes (google.com, cloudflare.com).
|
||||||
|
*/
|
||||||
|
private fun isHostAllowed(host: String?): Boolean {
|
||||||
|
if (host.isNullOrBlank()) return false
|
||||||
|
val h = host.lowercase()
|
||||||
|
return ALLOWED_DOMAINS.any { d -> h == d || h.endsWith(".$d") }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// WebView host allow-list. Login navigation is restricted to claude.ai and the SSO/CDN
|
||||||
|
// hosts it redirects through. This MUST include the SSO providers (Google, Apple) and
|
||||||
|
// Cloudflare challenge hosts — OAuth happens as same-WebView full-page redirects, so
|
||||||
|
// dropping any of these breaks sign-in. Subdomains are matched by dot-suffix.
|
||||||
|
private val ALLOWED_DOMAINS = listOf(
|
||||||
|
"claude.ai",
|
||||||
|
"anthropic.com",
|
||||||
|
"google.com", // covers accounts.google.com
|
||||||
|
"gstatic.com",
|
||||||
|
"googleusercontent.com",
|
||||||
|
"googleapis.com",
|
||||||
|
"apple.com",
|
||||||
|
"icloud.com",
|
||||||
|
"cloudflare.com", // covers challenges.cloudflare.com
|
||||||
|
"cloudflareinsights.com",
|
||||||
|
"recaptcha.net"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class PreferencesManager(context: Context) {
|
|||||||
// (Previously we deleted the last point and re-added in place — but the foreground loop
|
// (Previously we deleted the last point and re-added in place — but the foreground loop
|
||||||
// refreshes every 30s, well inside this 2-min window, so history could never grow past a
|
// refreshes every 30s, well inside this 2-min window, so history could never grow past a
|
||||||
// single point while the app was open and the chart stayed on "Collecting history…".)
|
// single point while the app was open and the chart stayed on "Collecting history…".)
|
||||||
if (history.isNotEmpty() && now - history.last().epochMs < MIN_HISTORY_GAP_MS) return
|
if (!shouldRecordHistory(history.lastOrNull()?.epochMs, now, MIN_HISTORY_GAP_MS)) return
|
||||||
history.add(
|
history.add(
|
||||||
UsageSnapshot(
|
UsageSnapshot(
|
||||||
epochMs = now,
|
epochMs = now,
|
||||||
@@ -153,10 +153,21 @@ class PreferencesManager(context: Context) {
|
|||||||
private const val KEY_NOTIFY_ENABLED = "notify_enabled"
|
private const val KEY_NOTIFY_ENABLED = "notify_enabled"
|
||||||
private const val KEY_AUTH_FAILS = "auth_fail_count"
|
private const val KEY_AUTH_FAILS = "auth_fail_count"
|
||||||
|
|
||||||
private const val MIN_HISTORY_GAP_MS = 2 * 60 * 1000L // collapse readings <2 min apart
|
internal const val MIN_HISTORY_GAP_MS = 2 * 60 * 1000L // collapse readings <2 min apart
|
||||||
private const val HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000L // keep 7 days
|
private const val HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000L // keep 7 days
|
||||||
private const val MAX_HISTORY_POINTS = 600
|
private const val MAX_HISTORY_POINTS = 600
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure throttle decision for [recordHistory]: should a new point be appended?
|
||||||
|
* Returns false only when a previous point exists ([lastEpochMs] != null) AND the gap to
|
||||||
|
* [now] is below [minGapMs]; true otherwise (including the first-ever point, lastEpochMs == null).
|
||||||
|
* No Android dependencies — kept separate so the throttle rule is unit-testable.
|
||||||
|
*/
|
||||||
|
internal fun shouldRecordHistory(lastEpochMs: Long?, now: Long, minGapMs: Long): Boolean {
|
||||||
|
if (lastEpochMs == null) return true
|
||||||
|
return now - lastEpochMs >= minGapMs
|
||||||
|
}
|
||||||
|
|
||||||
fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences {
|
fun createSecurePrefs(context: Context, onFallback: (() -> Unit)? = null): android.content.SharedPreferences {
|
||||||
return try {
|
return try {
|
||||||
buildEncryptedPrefs(context)
|
buildEncryptedPrefs(context)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/widget_root"
|
android:id="@+id/widget_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
android:src="@android:drawable/ic_menu_rotate"
|
android:src="@android:drawable/ic_menu_rotate"
|
||||||
android:background="@android:color/transparent"
|
android:background="@android:color/transparent"
|
||||||
android:tint="#999999"
|
android:tint="#999999"
|
||||||
|
tools:ignore="UseAppTint"
|
||||||
android:contentDescription="Refresh" />
|
android:contentDescription="Refresh" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import me.khodak.claudeusage.data.PreferencesManager
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-JVM tests for the history-throttle decision extracted from PreferencesManager.recordHistory.
|
||||||
|
* No Android Context — exercises only PreferencesManager.shouldRecordHistory.
|
||||||
|
*/
|
||||||
|
class HistoryThrottleTest {
|
||||||
|
|
||||||
|
private val gap = PreferencesManager.MIN_HISTORY_GAP_MS
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun returnsTrueWhenNoPreviousPoint() {
|
||||||
|
// First-ever reading (lastEpochMs == null) is always recorded.
|
||||||
|
assertTrue(PreferencesManager.shouldRecordHistory(null, now = 1_000_000L, minGapMs = gap))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun returnsFalseWhenWithinGap() {
|
||||||
|
val last = 1_000_000L
|
||||||
|
// One millisecond before the gap elapses → throttled.
|
||||||
|
val now = last + gap - 1L
|
||||||
|
assertFalse(PreferencesManager.shouldRecordHistory(last, now, gap))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun returnsTrueExactlyAtGapBoundary() {
|
||||||
|
val last = 1_000_000L
|
||||||
|
// Exactly at the gap (>= boundary is inclusive) → recorded.
|
||||||
|
val now = last + gap
|
||||||
|
assertTrue(PreferencesManager.shouldRecordHistory(last, now, gap))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun returnsTrueAfterGap() {
|
||||||
|
val last = 1_000_000L
|
||||||
|
val now = last + gap + 1L
|
||||||
|
assertTrue(PreferencesManager.shouldRecordHistory(last, now, gap))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-JVM tests for PaceCalc.compute. The clock is injected via the `now` parameter, so these are
|
||||||
|
* deterministic and require no Android framework.
|
||||||
|
*/
|
||||||
|
class PaceCalcTest {
|
||||||
|
|
||||||
|
private val window = PaceCalc.SESSION_WINDOW_MS // 5h
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeReturnsPaceForValidFutureReset() {
|
||||||
|
// Place "now" halfway through the window (elapsedFraction = 0.5, within [0.03, 1.0)).
|
||||||
|
val resetEpoch = 10_000_000_000L
|
||||||
|
val now = resetEpoch - window / 2
|
||||||
|
val pace = PaceCalc.compute(usedPct = 25f, resetEpoch = resetEpoch, windowMs = window, now = now)
|
||||||
|
|
||||||
|
assertNotNull(pace)
|
||||||
|
val p = pace!!
|
||||||
|
// markerPct mirrors elapsedFraction*100 → ~50, and must stay in 0..100.
|
||||||
|
assertTrue("markerPct in range", p.markerPct in 0..100)
|
||||||
|
assertEquals50(p.markerPct)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun markerPctAlwaysInRangeNearWindowEnd() {
|
||||||
|
// Near the end of the window (95% elapsed, still < 1.0) marker approaches 100, never exceeds.
|
||||||
|
// Kept off the exact boundary so Float rounding of (window-elapsed)/window can't tip it to 1.0.
|
||||||
|
val resetEpoch = 10_000_000_000L
|
||||||
|
val now = resetEpoch - (window * 5L / 100L) // 95% elapsed
|
||||||
|
val pace = PaceCalc.compute(usedPct = 80f, resetEpoch = resetEpoch, windowMs = window, now = now)
|
||||||
|
|
||||||
|
assertNotNull(pace)
|
||||||
|
assertTrue(pace!!.markerPct in 0..100)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeReturnsNullForPastReset() {
|
||||||
|
// Reset already passed → elapsedFraction >= 1.0 → null.
|
||||||
|
val resetEpoch = 10_000_000_000L
|
||||||
|
val now = resetEpoch + 60_000L
|
||||||
|
assertNull(PaceCalc.compute(usedPct = 50f, resetEpoch = resetEpoch, windowMs = window, now = now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeReturnsNullTooEarlyInWindow() {
|
||||||
|
// Less than 3% elapsed → not enough signal → null.
|
||||||
|
val resetEpoch = 10_000_000_000L
|
||||||
|
val now = resetEpoch - window + (window * 0.01f).toLong() // 1% elapsed
|
||||||
|
assertNull(PaceCalc.compute(usedPct = 5f, resetEpoch = resetEpoch, windowMs = window, now = now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeReturnsNullForNonPositiveReset() {
|
||||||
|
assertNull(PaceCalc.compute(usedPct = 50f, resetEpoch = 0L, windowMs = window))
|
||||||
|
assertNull(PaceCalc.compute(usedPct = 50f, resetEpoch = -1L, windowMs = window))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeReturnsNullForNegativeUsedPct() {
|
||||||
|
val resetEpoch = 10_000_000_000L
|
||||||
|
val now = resetEpoch - window / 2
|
||||||
|
assertNull(PaceCalc.compute(usedPct = -1f, resetEpoch = resetEpoch, windowMs = window, now = now))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertEquals50(markerPct: Int) {
|
||||||
|
// elapsedFraction 0.5 → (0.5*100).toInt() == 50.
|
||||||
|
org.junit.Assert.assertEquals(50, markerPct)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.GregorianCalendar
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-JVM tests for PeakHours.isPeak. The clock is injected via `now`, and PeakHours evaluates the
|
||||||
|
* window in America/Los_Angeles regardless of the device timezone — so we build each instant in PT
|
||||||
|
* explicitly, making these deterministic without touching the system clock.
|
||||||
|
*/
|
||||||
|
class PeakHoursTest {
|
||||||
|
|
||||||
|
private val pt: TimeZone = TimeZone.getTimeZone("America/Los_Angeles")
|
||||||
|
|
||||||
|
/** Absolute epoch millis for the given wall-clock time in PT on a fixed reference date. */
|
||||||
|
private fun ptInstant(year: Int, month0: Int, day: Int, hour: Int): Long {
|
||||||
|
val cal = GregorianCalendar(pt)
|
||||||
|
cal.clear()
|
||||||
|
cal.set(year, month0, day, hour, 0, 0)
|
||||||
|
return cal.timeInMillis
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun activeOnWeekdayInsideWindow() {
|
||||||
|
// 2026-06-10 is a Wednesday. 8 AM PT is inside 5–11 AM.
|
||||||
|
assertTrue(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 8)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun inactiveAtStartBoundaryIsInclusiveButBeforeIsNot() {
|
||||||
|
// 5 AM PT is inside (inclusive start); 4 AM PT is outside.
|
||||||
|
assertTrue(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 5)))
|
||||||
|
assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 4)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun inactiveAtEndBoundaryExclusive() {
|
||||||
|
// 11 AM PT is the exclusive end → not peak.
|
||||||
|
assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 11)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun inactiveOnWeekendEvenInsideHours() {
|
||||||
|
// 2026-06-13 is a Saturday, 2026-06-14 is a Sunday. 8 AM PT, but weekend → not peak.
|
||||||
|
assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 13, 8)))
|
||||||
|
assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 14, 8)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun inactiveOnWeekdayEvening() {
|
||||||
|
// 8 PM PT Wednesday → outside the morning window.
|
||||||
|
assertFalse(PeakHours.isPeak(ptInstant(2026, Calendar.JUNE, 10, 20)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package me.khodak.claudeusage
|
||||||
|
|
||||||
|
import me.khodak.claudeusage.data.UsageData
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-JVM tests for the UsageData data class: derived metrics and the mergedWith fallback logic.
|
||||||
|
* UsageData has no Android dependencies, so these run on the plain JVM.
|
||||||
|
*/
|
||||||
|
class UsageDataTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun progressPercentMessagesPath() {
|
||||||
|
// messagesLimit > 0 and effectiveUsed derived from messagesUsed → 25/100 = 25%.
|
||||||
|
val d = UsageData(messagesUsed = 25, messagesLimit = 100)
|
||||||
|
assertEquals(25, d.progressPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun progressPercentMessagesPathCoercedTo100() {
|
||||||
|
// Used above limit must clamp to 100, never exceed.
|
||||||
|
val d = UsageData(messagesUsed = 150, messagesLimit = 100)
|
||||||
|
assertEquals(100, d.progressPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun progressPercentUtilizationPathWhenNoMessages() {
|
||||||
|
// No messages limit → fall through to utilization, truncated to Int and coerced.
|
||||||
|
val d = UsageData(fiveHourUtilization = 42.9f)
|
||||||
|
assertEquals(42, d.progressPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun progressPercentUtilizationCoercedTo100() {
|
||||||
|
val d = UsageData(fiveHourUtilization = 130f)
|
||||||
|
assertEquals(100, d.progressPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun progressPercentZeroWhenNoReadings() {
|
||||||
|
assertEquals(0, UsageData().progressPercent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun effectiveUsedPrefersMessagesUsed() {
|
||||||
|
assertEquals(30, UsageData(messagesUsed = 30, messagesLimit = 100).effectiveUsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun effectiveUsedDerivedFromRemaining() {
|
||||||
|
// No messagesUsed, but remaining + limit present → limit - remaining.
|
||||||
|
val d = UsageData(messagesRemaining = 40, messagesLimit = 100)
|
||||||
|
assertEquals(60, d.effectiveUsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun effectiveUsedUnknownIsNegativeOne() {
|
||||||
|
assertEquals(-1, UsageData().effectiveUsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun effectiveRemainingPrefersRemaining() {
|
||||||
|
assertEquals(40, UsageData(messagesRemaining = 40, messagesLimit = 100).effectiveRemaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun effectiveRemainingDerivedFromUsed() {
|
||||||
|
val d = UsageData(messagesUsed = 70, messagesLimit = 100)
|
||||||
|
assertEquals(30, d.effectiveRemaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun effectiveRemainingUnknownIsNegativeOne() {
|
||||||
|
assertEquals(-1, UsageData().effectiveRemaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hasAnyReadingTrueWithUtilization() {
|
||||||
|
assertTrue(UsageData(fiveHourUtilization = 10f).hasAnyReading)
|
||||||
|
assertTrue(UsageData(weeklyUtilization = 5f).hasAnyReading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hasAnyReadingTrueWithRateLimitData() {
|
||||||
|
// hasRateLimitData true when messagesLimit > 0.
|
||||||
|
assertTrue(UsageData(messagesLimit = 100).hasAnyReading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hasAnyReadingFalseWhenEmpty() {
|
||||||
|
assertFalse(UsageData().hasAnyReading)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mergedWithEmptyFetchKeepsPreviousMetrics() {
|
||||||
|
// (a) previous has a reading; this fetch is empty (all defaults) → previous metrics kept.
|
||||||
|
val previous = UsageData(
|
||||||
|
fiveHourUtilization = 55f,
|
||||||
|
utilizationResetAtEpoch = 1_000L,
|
||||||
|
weeklyUtilization = 20f,
|
||||||
|
weeklyResetAtEpoch = 2_000L,
|
||||||
|
messagesLimit = 100,
|
||||||
|
messagesUsed = 40
|
||||||
|
)
|
||||||
|
val emptyFetch = UsageData(isLoggedIn = true)
|
||||||
|
val merged = emptyFetch.mergedWith(previous)
|
||||||
|
|
||||||
|
assertEquals(55f, merged.fiveHourUtilization, 0f)
|
||||||
|
assertEquals(20f, merged.weeklyUtilization, 0f)
|
||||||
|
assertEquals(100, merged.messagesLimit)
|
||||||
|
assertEquals(40, merged.messagesUsed)
|
||||||
|
assertEquals(1_000L, merged.utilizationResetAtEpoch)
|
||||||
|
assertEquals(2_000L, merged.weeklyResetAtEpoch)
|
||||||
|
// Login context from the fresh (empty) attempt is carried forward.
|
||||||
|
assertTrue(merged.isLoggedIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mergedWithPartialFetchUnionsBothWindows() {
|
||||||
|
// (b) previous only had five-hour; this fetch only has weekly → result has both.
|
||||||
|
val previous = UsageData(
|
||||||
|
fiveHourUtilization = 33f,
|
||||||
|
utilizationResetAtEpoch = 500L
|
||||||
|
)
|
||||||
|
val partial = UsageData(
|
||||||
|
weeklyUtilization = 77f,
|
||||||
|
weeklyResetAtEpoch = 900L
|
||||||
|
)
|
||||||
|
val merged = partial.mergedWith(previous)
|
||||||
|
|
||||||
|
assertEquals(33f, merged.fiveHourUtilization, 0f)
|
||||||
|
assertEquals(500L, merged.utilizationResetAtEpoch)
|
||||||
|
assertEquals(77f, merged.weeklyUtilization, 0f)
|
||||||
|
assertEquals(900L, merged.weeklyResetAtEpoch)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mergedWithNullPreviousReturnsThis() {
|
||||||
|
// (c) previous == null → returns this unchanged.
|
||||||
|
val fetch = UsageData(fiveHourUtilization = 12f)
|
||||||
|
assertSame(fetch, fetch.mergedWith(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mergedWithPreviousWithoutReadingReturnsThis() {
|
||||||
|
// previous has no usable reading → this is returned unchanged.
|
||||||
|
val fetch = UsageData(fiveHourUtilization = 12f)
|
||||||
|
assertSame(fetch, fetch.mergedWith(UsageData()))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user