Phase 8 was scoped as docs, coverage and the cutover. The AC-5 walk turned it into a phase with code in it, which is the outcome the walk existed to produce - so the record is longer than a phase-8 note would normally be. THEMING_AND_NAV.md gains "Phase 8 as landed": what the walk confirmed, the two defects it found and why they are phase 2's trap repeating, the role sweep that says which Material roles are still unmapped and which of them have a reader, the coverage-glob narrowing, and the adb-reverse rig note for the next walk. The phase 2 note is amended where it claimed the card shadow was the milestone's only visible change to an untouched instance - it is now one of three, and the third (an untouched instance drawing a hero, because brand.hero carries the env default) is a correction to the record with no code behind it. PLAN.md's M12 entry stopped at phase 2; it now runs through 6, records phase 8 including the defects, and drops the same "one deliberate change" claim. COVERAGE_PLAN.md gains an amendment section explaining why the ui/theme/** directory glob became wrong the moment M12 put three pure resolvers behind it, and the general rule it suggests: prefer file globs to directory globs once a directory is mixed. Its own §5 already forbade excluding testable code. Pairs with Android-app phase 8. Co-Authored-By: Claude <noreply@anthropic.com>
12 KiB
Android App — Test Coverage Plan
Goal: clear the SonarQube coverage quality gate (new_coverage ≥ 50%) for
Runic-Gateway-Android-app, and leave a durable unit-test culture behind it. Companion to
PLAN.md §12.1 (the JaCoCo wiring that made coverage measurable).
1. Current state (2026-07-22, post Android-app#26)
The JaCoCo→Sonar wiring is live on main, so coverage is now real — and the gate is failing:
| Metric | Value |
|---|---|
| Quality gate | ERROR (one condition) |
new_coverage |
16.4% (threshold ≥ 50%) |
overall coverage |
16.3% |
| lines to cover | 3,188 |
| covered | 542 |
Everything else on the gate is green (reliability/security/maintainability A, duplication 0%).
Because this is the first measured version, Sonar's "new code" window is essentially the whole
codebase, so new_coverage ≈ overall coverage — to pass we need to roughly triple covered
lines, from 542 to ~1,600.
Where the uncovered lines are
Bucketed from Sonar's per-file uncovered_lines (exclusions from #26 already applied, so *Screen.kt
is absent):
| Bucket | Files | Lines to cover | Covered % | Verdict |
|---|---|---|---|---|
| ViewModels | 28 | 1,153 | 0.1% | Test — the dominant lever; no ViewModel has any test |
| DTOs | 12 | 648 | 26.7% | Test — trivial (serialization); a pattern already exists |
| Repositories | 11 | 274 | 11.3% | Test — fake the API interface |
| core/* (logic) | 21 | 365 | 56.2% | Test — top up the partially-covered ones |
UI composables (*Components.kt, BlockRenderer, …) |
7 | 255 | 3.9% | Exclude — not JVM-unit-testable, and *Screen.kt's exclusion missed them |
| core/push (Android services) | 4 | ~191 | ~0% | Exclude (or Robolectric later) — foreground service / notifications |
core/auth Encrypted* stores |
3 | 76 | 0% | Exclude — Android Keystore / EncryptedSharedPreferences |
framework glue (di/, RunicGatewayApp, LocalAssetResolver) |
3 | ~17 | 0% | Exclude |
Two levers, applied together: (a) stop counting code a JVM unit test physically cannot execute, and (b) actually test the logic — ViewModels, DTOs, repositories, core utilities.
2. Strategy & projected math
Numbers below are line-coverage projections against Sonar's lines_to_cover. They are estimates, but
grounded in the current per-bucket totals.
Phase 0 — Broaden coverage exclusions (no tests; ~½ day)
Move non-unit-testable code out of the coverage denominator (it stays in analysis — bugs and
smells are still reported). Extend sonar.coverage.exclusions in sonar-project.properties:
sonar.coverage.exclusions=\
app/src/main/java/**/ui/**/*Screen.kt,\
app/src/main/java/**/ui/**/*Screen*.kt,\
app/src/main/java/**/ui/**/*Components.kt,\
app/src/main/java/**/ui/page/BlockRenderer.kt,\
app/src/main/java/**/ui/components/**,\
app/src/main/java/**/ui/shard/FrameFields.kt,\
app/src/main/java/**/ui/theme/**,\
app/src/main/java/**/ui/LocalAssetResolver.kt,\
app/src/main/java/**/RunicApp.kt,\
app/src/main/java/**/MainActivity.kt,\
app/src/main/java/**/*Application.kt,\
app/src/main/java/**/RunicGatewayApp.kt,\
app/src/main/java/**/di/**,\
app/src/main/java/**/core/push/PushService.kt,\
app/src/main/java/**/core/push/PushManager.kt,\
app/src/main/java/**/core/push/PushNotifier.kt,\
app/src/main/java/**/core/push/NtfyStreamClient.kt,\
app/src/main/java/**/core/auth/Encrypted*.kt
Verify each glob targets composable-only / framework-only files before committing (e.g. confirm
FrameFields.ktholds no testable logic). Keep the pure-logic push files in coverage (PushPreferences,PushTickle,NtfyTopic,PushStreams) — they already have tests.
Effect: denominator ~3,188 → ~2,650; covered ~542 → ~532. Coverage ≈ 20%. (Removing ~540 lines that were ~2% covered.)
Phase 1 — DTO serialization tests (highest ROI; ~1 day) → ~34%
DTOs are @Serializable data classes; test them with kotlinx-serialization round-trips against
representative backend JSON. The pattern already exists (AccountDtoTest, AuthDtoTest,
NotificationsDtoTest, PlayerShardDtoTest, ShardDtoTest). Add/extend:
- New:
AdminDto(111 uncov — biggest single file),WikiDto(51),PublicDto(32),PageDto(16),PostDto(12),ContactDto(11),SsoDto,PageDto. - Extend to ~85%:
PlayerShardDto(30.8%),ShardDto(35.6%),AccountDto(50.7%),AuthDto(47.4%).
Target DTOs to ~85%: +~380 covered lines → covered ~912 / ~2,650 ≈ 34%.
Phase 2 — ViewModel tests (the big one; ~3–4 days) → clears the gate
28 ViewModels, ~1,153 lines, currently 0%. This is where the gate is won. Requires a small test
harness (§3). Each test drives the VM with fake collaborators and asserts UiState transitions
(loading → success/error, form validation, actions).
Priority by uncovered lines:
LoginViewModel(116),AccountViewModel(104),CharactersViewModel(77),AdminContentViewModel(74),NotificationsViewModel(66),TrustedDevicesViewModel(63),ShardViewModel(59)HousesViewModel(51),GovernorsViewModel(47),AdminDashboardViewModel(43),AdminSupportViewModel(41),ChampsViewModel(40),AdminModerationViewModel(40),GuildsViewModel(39),RecoveryCodesViewModel(38),ConnectViewModel(37),ContactViewModel(36),VendorsViewModel(32),AppViewModel(29)- The small ones (
PostViewModel,NewsViewModel,WikiViewModel,CharacterViewModel,MyHousesViewModel,WikiPageViewModel,PageViewModel,HomeViewModel,SessionViewModel)
Target ViewModels to ~70%: +~800 covered lines → covered ~1,712 / ~2,650 ≈ 65%. ✅ Gate passes.
Phases 0 + 2 alone (skipping DTOs) already reach ~50.5% — but DTOs are cheap insurance and Phase 1 lands first because it de-risks the harness work.
Phase 3 — Repository tests (~1–2 days) → margin
Repositories map API Response/exceptions to ApiResult; test with a fake *Api interface (or
OkHttp MockWebServer). Priority: AuthRepository (89), ConnectionRepository (43),
ShardRepository (27), AdminRepository (21), then the small content/wiki/player repos. Target ~70%:
+~160 lines → buffer well above 50% and resilience as the new-code window narrows.
Phase 4 — core/net + core/auth top-up (~½–1 day) → durability
Fill the partially-covered utilities: TokenAuthenticator (31), ShardStreamClient (36),
HostSelectionInterceptor (9), ApiResult (6), WebHandoff, WebsiteUrls, DeviceNameProvider,
ServerPreferences, AppConfig.
Trajectory
| After | Denominator | Covered | Coverage |
|---|---|---|---|
| Today | 3,188 | 542 | 16.3% |
| Phase 0 (exclusions) | ~2,650 | ~532 | ~20% |
| Phase 1 (DTOs) | ~2,650 | ~912 | ~34% |
| Phase 2 (ViewModels) | ~2,650 | ~1,712 | ~65% ✅ |
| Phase 3 (repos) | ~2,650 | ~1,872 | ~71% |
| Phase 4 (core) | ~2,650 | ~2,000+ | ~75%+ |
3. Test infrastructure to add
The existing suite tests pure-logic classes only; ViewModel/coroutine testing needs a little scaffold.
kotlinx-coroutines-test is already a testImplementation dependency.
MainDispatcherRule (JUnit4) — swaps Dispatchers.Main (used by viewModelScope) for a test
dispatcher:
// app/src/test/java/com/runicgateway/app/util/MainDispatcherRule.kt
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
private val dispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() {
override fun starting(d: Description) = Dispatchers.setMain(dispatcher)
override fun finished(d: Description) = Dispatchers.resetMain()
}
ViewModel test pattern — hand-written fakes (matches the repo's existing no-mock convention; no new dependency):
class LoginViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private class FakeAuthRepository(var result: LoginResult) : AuthRepository { /* stub the seam */ }
@Test fun `blank credentials surface INVALID_CREDENTIALS without a network call`() = runTest {
val vm = LoginViewModel(FakeAuthRepository(LoginResult.Success), /* … */)
vm.submit()
assertEquals(LoginError.INVALID_CREDENTIALS, vm.state.value.error)
}
}
- Assert on
viewModel.state.valueafteradvanceUntilIdle(); or collect theStateFlowin a backgroundlaunchwhen you need to see intermediate (loading) states. - Optional deps (decide once):
mockkwould cut fake-writing for wide interfaces, andturbinesimplifies Flow assertions. Recommendation: stay with hand fakes to match convention; revisit only if VM tests get boilerplate-heavy.
4. Execution notes
- CI already runs
./gradlew testDebugUnitTest jacocoTestReportbefore the scan (sonarqube.yml), so new tests count automatically on merge tomain. Locally on this machine: JDK 21 needs-Pksp.incremental=false. - Sonar recomputes the gate on the post-merge scan; there's no way to fully confirm the number pre-merge. Land phases as separate PRs (0, 1, 2, …) so coverage climbs visibly and reviews stay small.
sonar.coverage.exclusionsremoves files from coverage only — analysis still flags bugs/smells in them, so excluding UI/framework code is safe.- Android-framework code deferred, not abandoned: push services and
Encrypted*stores are excluded now; if we want them covered later, add Robolectric (testImplementation) and aRobolectricTestRunnersuite rather than instrumented tests, to keep it in the fast JVMtestsource set the scan already consumes.
5. Definition of done
new_coverage ≥ 50%and the SonarQube quality gate is green.MainDispatcherRule+ a documented ViewModel test pattern exist and are reused.- Coverage exclusions list only genuinely non-unit-testable files (UI composables, Android-framework glue) — no ViewModel, repository, DTO, or pure core-logic file is excluded.
6. Amendment (2026-08-08, M12 phase 8): ui/theme/** narrowed to one file
A directory glob in the exclusion list went stale as soon as another milestone put testable code
in that directory. §2 phase 0 excluded app/src/main/java/**/ui/theme/** because at the time the
directory held Color.kt, Type.kt and the composables — constants and composable bodies, nothing
a JVM test could execute. M12 then added three pure resolvers to it: ShardPalette,
ShardStructure and ShardTypeface, which exist precisely so the theming milestone's no-op
invariants could be plain JVM assertions. JaCoCo measures them at 98%, 100% and 100%, and the
glob was discarding every line.
The exclusion is now the single file it was really about, ui/theme/Theme.kt (the composable, 52%).
Everything else in ui/theme/ is measured and all of it covers at 93% or better.
Two things worth carrying forward:
- This did not rescue the gate and was not meant to. M12's already-measured code
(
data/appearance/at 100%,ui/navigation/at 93–100%) clearsnew_coverage ≥ 50on its own. The point is that a future change deleting those resolvers' tests would now move the number, where before it would not have — the exclusion was hiding well-tested code, which is the opposite of what §1 built the list for and what §5's third bullet asks for. - Prefer file globs to directory globs when a directory is mixed.
ui/components/**stays a directory glob and correctly so:BrandAssets.ktsits at 11% because onlybrandAssetUrlis pure, and the rest is composable bodies. The distinction is whether the directory is uniformly untestable, not whether it is underui/.