Files
darken 3651bb3d55 refactor(upgrade): Converge GPlay billing on the canonical stack
Replaces capod's older billing core, upgrade UI and their tests with the
canonical sdmaid-se stack at the pinned revision.

Core (gplay): BillingManager/BillingConnection/BillingConnectionProvider on
billing 8.3 with the centralized connect loop, merging purchases-listener
overlay and the canonical ack pipeline; the dying ack collector, the
ackedTokens gate and the in-billing foreground loop are gone. Full canonical
exception set (internal/network/offer-unavailable added), OurSku with capod's
product ids, BillingCache with snapshot()/episode-guarded stampLastProState.

FOSS: UpgradeControlFoss becomes UpgradeRepoFoss and exposes the canonical API
surface over capod's RETAINED FossUpgrade/FossCache schema — existing supporter
records must keep decoding.

Diagnostics: UpgradeDiagnostics + gplay/foss implementations, read by
RecorderModule next to CurriculumVitae's Pro history as two independent,
isolated header reads.

UI: canonical upgrade screens for both flavors under common/upgrade/ui with
capod chrome (M3 AlertDialog keeping rotation-safety, capod Scaffold, capod
previews). Nav.Main.Upgrade gains `forced`. Entitlement refresh moves to a
per-resume, unthrottled MainActivity call.

Strings reuse capod's existing translated ids wherever equivalent; only
referenced-but-missing ones are authored.

mockk 1.12.4 -> 1.14.9: 1.12.4 cannot synthesize a sealed-class return value
while recording, which the ported restore tests need.
2026-07-29 14:05:26 +02:00

3.5 KiB

description, paths
description paths
Unit test conventions — JUnit 5, kotest assertions, mockk, BaseTest, and which Gradle task runs which source set
app/src/test/**
app/src/testFoss/**
app/src/testGplay/**
app/build.gradle.kts
buildSrc/src/main/java/Dependencies.kt

Testing

The stack here is not the Android default — check this before reaching for a familiar library.

Libraries

  • JUnit 5 (org.junit.jupiter.api.Test). Gradle sets useJUnitPlatform().
  • kotest for assertions: io.kotest.matchers.shouldBe, shouldBeNull, shouldBeInstanceOf, shouldContainExactly, io.kotest.assertions.throwables.shouldThrow. Use kotest for new assertions — MediaControlTest still uses JUnit Assertions.* and is a legacy exception.
  • mockk for mocking. Not Mockito.
  • Turbine is not a dependency. testhelpers.flow.FlowTest provides a Flow<T>.test() helper — use it rather than adding one.

Base classes

Extend testhelpers.BaseTest, or the applicable specialized base that already extends it:

  • BaseBlePodsTest — BLE advertisement parsing per pod model
  • BaseAapSessionTest — AAP protocol/session tests

BaseTest installs a JUnitLogger and calls unmockkAll() in @AfterAll. Skipping it can leave global mockk and logging state behind for later test classes.

The only exceptions are the Robolectric-backed tests (Compose UI via testhelpers.compose.BaseComposeRobolectricTest, and the few DataStore-backed ones such as CurriculumVitaeProHistoryTest), which use JUnit 4 @RunWith/@Rule via junit-vintage-engine. Don't copy that pattern for a plain unit test.

Source sets and Gradle tasks

Each task compiles and runs only its own flavor — running the wrong one silently skips your test.

Test location Task
app/src/test/ (shared) either; run both before pushing
app/src/testFoss/ ./gradlew testFossDebugUnitTest
app/src/testGplay/ ./gradlew testGplayDebugUnitTest

CI runs both. Flavor-specific tests are for code that only exists in that flavor — billing in gplay, the sponsor-based upgrade flow in foss.

Helpers that already exist

  • runTest2(autoCancel, context, expectedError, testBody) in testhelpers/coroutine/TestExtensions.kt — use expectedError = SomeException::class instead of hand-rolling a throws-assertion around runTest
  • FakeDataStoreValue<T>(initial) in testhelpers/datastore/ — a working fake with a real backing MutableStateFlow; read/write it through .value and pass .mock to the code under test

Mocking DataStoreValue

DataStoreValue.value() and .value(T) are extension functions (DataStoreValue.kt:54,56), not members, so MockK cannot stub them. They delegate to flow.first() and update { } — stub those:

every { someSetting.flow } returns flowOf(value)   // covers .value() reads
coVerify { someSetting.update(any()) }             // verifies .value(x) writes

UpgradeRepoGplayTest uses this shape. Prefer FakeDataStoreValue when you need reads and writes to actually round-trip.

Reading ViewModel state

ViewModel2.asLiveState() is stateIn(..., initialValue = null).filterNotNull() with SharingStarted.WhileSubscribed(5_000) — so vm.state is a Flow, not a StateFlow, and has no .value to read. Collect it: vm.state.first() is the established pattern across the existing ViewModel tests. Because the upstream only runs while subscribed, a test that never collects sees nothing happen at all.