P2 Stream D: ThemeSelectionScreen (replaces ThemePickerDialog)

Full-screen theme picker with 11 theme previews matching iOS
ThemeSelectionView. Live preview on tap. Old dialog deleted and
call-sites migrated to the new route.

Tests use the state-logic fallback pattern (plain kotlin.test rather
than runComposeUiTest) because Compose UI testing in commonTest for
this KMP project is flaky — the existing ThemeManager uses mutableStateOf
plus platform-backed ThemeStorage, which doesn't play well with the
recomposer on iosSimulatorArm64. The behavior under test is identical:
helpers in ThemeSelectionScreenState drive the same code paths the
composable invokes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 12:50:00 -05:00
parent 0d50726490
commit 6b3e64661f
7 changed files with 489 additions and 230 deletions
@@ -0,0 +1,126 @@
package com.tt.honeyDue.ui.screens.theme
import com.tt.honeyDue.ui.theme.AppThemes
import com.tt.honeyDue.ui.theme.ThemeManager
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNotSame
import kotlin.test.assertSame
import kotlin.test.assertTrue
/**
* P2 Stream D — ThemeSelectionScreen tests.
*
* These tests exercise the state-logic backing the screen. We intentionally
* use plain `kotlin.test` rather than `runComposeUiTest { }` because
* Compose UI testing in commonTest for a KMP project with `ThemeManager`
* (which uses `mutableStateOf` and a platform-backed `ThemeStorage`) is
* flaky — the recomposer / Dispatchers interplay breaks on iosSimulator.
*
* Instead we assert the public contract the screen relies on:
* - The 11 iOS themes are exposed by `ThemeManager.getAllThemes()` with
* the IDs and display names the row-cards render.
* - Tapping a row updates `ThemeManager.currentTheme` to the tapped id.
* - The "live header preview" simply reads `ThemeManager.currentTheme`,
* so selecting a theme advances that reactive value.
* - "Initially selected" is whatever `ThemeManager.currentTheme` returns
* at composition time — asserted by comparing ids.
*
* Mirrors iOS `iosApp/iosApp/Profile/ThemeSelectionView.swift`.
*/
class ThemeSelectionScreenTest {
private lateinit var originalThemeId: String
@BeforeTest
fun rememberOriginalTheme() {
originalThemeId = ThemeManager.currentTheme.id
}
@AfterTest
fun restoreOriginalTheme() {
ThemeManager.setTheme(originalThemeId)
}
// 1. Renders 11 theme cards with correct names and IDs.
@Test
fun allElevenThemesAreAvailableForTheScreen() {
val themes = ThemeManager.getAllThemes()
assertEquals(11, themes.size, "ThemeSelectionScreen must offer all 11 iOS themes")
val expectedIds = listOf(
"default", "teal", "ocean", "forest", "sunset", "monochrome",
"lavender", "crimson", "midnight", "desert", "mint"
)
assertEquals(expectedIds, themes.map { it.id })
// Each theme must have a non-blank displayName and description
themes.forEach { theme ->
assertTrue(theme.displayName.isNotBlank(), "${theme.id} needs displayName")
assertTrue(theme.description.isNotBlank(), "${theme.id} needs description")
}
}
// 2. Initially-selected theme matches ThemeManager.currentTheme.
@Test
fun initiallySelectedThemeReflectsThemeManager() {
ThemeManager.setTheme("ocean")
val selectedId = ThemeManager.currentTheme.id
assertEquals("ocean", selectedId, "Screen shows whatever ThemeManager reports as current")
// The card for that id is the one that renders the checkmark.
val oceanTheme = ThemeManager.getAllThemes().single { it.id == selectedId }
assertEquals("Ocean", oceanTheme.displayName)
}
// 3. Tapping a theme card changes ThemeManager.currentTheme to the tapped id.
@Test
fun tappingThemeCardUpdatesCurrentTheme() {
ThemeManager.setTheme("default")
assertEquals("default", ThemeManager.currentTheme.id)
// Simulate the screen's onThemeSelected callback.
ThemeSelectionScreenState.onThemeTap("crimson")
assertEquals("crimson", ThemeManager.currentTheme.id)
assertSame(AppThemes.Crimson, ThemeManager.currentTheme)
}
// 4. Live preview header reflects the selected theme's primary color.
// The preview reads `ThemeManager.currentTheme.lightPrimary` (or dark).
@Test
fun livePreviewTracksSelectedThemePrimaryColor() {
ThemeManager.setTheme("default")
val defaultPrimary = ThemeManager.currentTheme.lightPrimary
ThemeSelectionScreenState.onThemeTap("forest")
val forestPrimary = ThemeManager.currentTheme.lightPrimary
assertNotNull(forestPrimary)
assertNotSame(defaultPrimary, forestPrimary, "Primary color must update on theme tap")
assertEquals(AppThemes.Forest.lightPrimary, forestPrimary)
}
// 5. The screen's back-navigation callback is invoked when the user
// confirms. Verified by the state helper recording the call.
@Test
fun onConfirmNavigatesBack() {
var navigatedBack = false
ThemeSelectionScreenState.onConfirm(onBack = { navigatedBack = true })
assertTrue(navigatedBack, "Confirm/Done must call the back-navigation callback")
}
// 6. Tapping every theme in turn leaves currentTheme at the last tap —
// proves the reactive state machine across a full cycle.
@Test
fun stateSelectionFlowCyclesThroughAllThemes() {
val ids = ThemeManager.getAllThemes().map { it.id }
ids.forEach { id ->
ThemeSelectionScreenState.onThemeTap(id)
assertEquals(id, ThemeManager.currentTheme.id)
}
}
}