test(home): HomeActionMapperTest (JVM) + HomeContentRenderSmokeTest (instrumented)
Part 3 of the Home refactor. Adds the durable coverage the plan called for: - HomeActionMapperTest: 12 JVM cases over the lifted pure mapper (refresh states, withHomeActions pairing/daily/loading/error paths, toHomeLabel mc-drop, secondary cap of 3, C-HOME-001 primary/pending dedup) — locks the extraction as faithful. - HomeContentRenderSmokeTest: instrumented render net for Home (via the VM-free PairedHomePreviewScreen), light + dark — catches 'composes fine, crashes on first paint'. Runs on a THROWAWAY only (uninstalls app-under-test). JVM test green; androidTest compiles. Fills the 'Home has no UI test' gap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9e81c15350
commit
f7eb03417b
|
|
@ -0,0 +1,60 @@
|
|||
package app.closer.ui.home
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import app.closer.ui.theme.CloserTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Instrumented render smoke for the Home screen — the on-device net for the "composes fine, crashes
|
||||
* on first paint" class that JVM unit tests and static scanners can't catch. Created alongside the
|
||||
* HomeScreen decomposition (1921 -> 614 lines across ui/home/components/*) so any future extraction
|
||||
* or @Immutable change that breaks Home rendering is caught.
|
||||
*
|
||||
* Renders the self-contained, VM-free `PairedHomePreviewScreen` (which builds demo HomeUiState and
|
||||
* calls the private HomeContent) — a successful render exercises HomeContent + every extracted
|
||||
* cluster (header, status strip, primary card, action feed, pending, category grid). Both light and
|
||||
* dark are covered (Home has decoupled theme art + per-theme colors).
|
||||
*
|
||||
* ⛔ Run on a THROWAWAY emulator only — connectedDebugAndroidTest uninstalls the app-under-test,
|
||||
* which WIPES fixture data. Never run against the 5554/5556 fixture couple.
|
||||
* ANDROID_SERIAL=emulator-5558 ./gradlew :app:connectedDebugAndroidTest
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class HomeContentRenderSmokeTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun pairedHome_rendersHeaderAndStatusStrip_light() {
|
||||
composeRule.setContent {
|
||||
CloserTheme(darkTheme = false) { PairedHomePreviewScreen() }
|
||||
}
|
||||
composeRule.onNodeWithText("For tonight").assertIsDisplayed() // HomeHeader
|
||||
composeRule.onNodeWithText("Just for two").assertIsDisplayed() // HomeStatusStrip
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pairedHome_rendersHeader_dark() {
|
||||
composeRule.setContent {
|
||||
CloserTheme(darkTheme = true) { PairedHomePreviewScreen() }
|
||||
}
|
||||
composeRule.onNodeWithText("For tonight").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pairedHome_rendersUnansweredDailyCard() {
|
||||
composeRule.setContent {
|
||||
CloserTheme(darkTheme = false) {
|
||||
PairedHomePreviewScreen(dailyQuestionState = DailyQuestionState.UNANSWERED)
|
||||
}
|
||||
}
|
||||
// The unanswered primary daily card (PrimaryHomeActionCard) CTA.
|
||||
composeRule.onNodeWithText("Answer privately").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
package app.closer.ui.home
|
||||
|
||||
import app.closer.domain.model.LocalAnswer
|
||||
import app.closer.domain.model.Question
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Characterization tests for the pure Home action mapper (HomeActionMapper.kt), locking in the
|
||||
* behavior lifted out of HomeViewModel so the extraction is provably faithful and future edits are
|
||||
* guarded. Only the `internal` entry points (refreshDailyQuestionState, withHomeActions) are called
|
||||
* directly; they exercise every private helper (toHomeAction, buildDailyQuestionAction,
|
||||
* buildPendingActions, has*, toHomeLabel) transitively.
|
||||
*/
|
||||
class HomeActionMapperTest {
|
||||
|
||||
private fun question(id: String = "q1", category: String = "") =
|
||||
Question(id = id, text = "Test question?", category = category, type = "single_choice")
|
||||
|
||||
// ── refreshDailyQuestionState ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `refresh yields UNANSWERED when there is no daily question`() {
|
||||
val out = HomeUiState().refreshDailyQuestionState()
|
||||
assertEquals(DailyQuestionState.UNANSWERED, out.dailyQuestionState)
|
||||
assertFalse(out.hasRevealedToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh yields USER_ANSWERED_PARTNER_PENDING when only the user has answered`() {
|
||||
val out = HomeUiState(
|
||||
dailyQuestion = question("q1"),
|
||||
answerStats = HomeAnswerStats(answeredQuestionIds = setOf("q1"))
|
||||
).refreshDailyQuestionState()
|
||||
assertEquals(DailyQuestionState.USER_ANSWERED_PARTNER_PENDING, out.dailyQuestionState)
|
||||
assertFalse(out.hasRevealedToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh yields BOTH_ANSWERED when both answered this question`() {
|
||||
val out = HomeUiState(
|
||||
dailyQuestion = question("q1"),
|
||||
answerStats = HomeAnswerStats(answeredQuestionIds = setOf("q1")),
|
||||
hasPartnerAnsweredToday = true,
|
||||
partnerAnsweredQuestionId = "q1"
|
||||
).refreshDailyQuestionState()
|
||||
assertEquals(DailyQuestionState.BOTH_ANSWERED, out.dailyQuestionState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh marks REVEALED and hasRevealedToday when the user's latest answer is revealed`() {
|
||||
val out = HomeUiState(
|
||||
dailyQuestion = question("q1"),
|
||||
answerStats = HomeAnswerStats(
|
||||
answeredQuestionIds = setOf("q1"),
|
||||
latest = LocalAnswer(
|
||||
questionId = "q1", questionText = "Test question?",
|
||||
category = "", answerType = "single_choice", isRevealed = true
|
||||
)
|
||||
)
|
||||
).refreshDailyQuestionState()
|
||||
assertEquals(DailyQuestionState.REVEALED, out.dailyQuestionState)
|
||||
assertTrue(out.hasRevealedToday)
|
||||
}
|
||||
|
||||
// ── withHomeActions ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `withHomeActions clears all actions while loading`() {
|
||||
val out = HomeUiState(isLoading = true).withHomeActions()
|
||||
assertNull(out.primaryAction)
|
||||
assertTrue(out.secondaryActions.isEmpty())
|
||||
assertTrue(out.pendingActions.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withHomeActions clears the primary action on error`() {
|
||||
val out = HomeUiState(isLoading = false, error = "boom").withHomeActions()
|
||||
assertNull(out.primaryAction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withHomeActions surfaces pairing as the primary when unpaired`() {
|
||||
val out = HomeUiState(isLoading = false, isPaired = false).withHomeActions()
|
||||
assertEquals(HomeActionTarget.InvitePartner, out.primaryAction?.target)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withHomeActions surfaces the daily question when paired and unanswered`() {
|
||||
val out = HomeUiState(
|
||||
isLoading = false,
|
||||
isPaired = true,
|
||||
dailyQuestion = question("q1"),
|
||||
dailyQuestionState = DailyQuestionState.UNANSWERED
|
||||
).withHomeActions()
|
||||
assertEquals(HomeActionTarget.DailyQuestion, out.primaryAction?.target)
|
||||
assertEquals("Your daily question", out.primaryAction?.eyebrow)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withHomeActions humanizes the daily category metric and drops the mc token (toHomeLabel)`() {
|
||||
val out = HomeUiState(
|
||||
isLoading = false,
|
||||
isPaired = true,
|
||||
dailyQuestion = question("q1", category = "daily_fun_mc"),
|
||||
dailyQuestionState = DailyQuestionState.UNANSWERED
|
||||
).withHomeActions()
|
||||
assertEquals("Daily Fun", out.primaryAction?.metric)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withHomeActions caps secondary actions at three`() {
|
||||
val out = HomeUiState(
|
||||
isLoading = false,
|
||||
isPaired = true,
|
||||
dailyQuestion = question("q1"),
|
||||
dailyQuestionState = DailyQuestionState.UNANSWERED,
|
||||
hasWaitingGame = true,
|
||||
hasActiveChallenge = true,
|
||||
hasUpcomingDatePlan = true,
|
||||
hasPendingDateReflection = true,
|
||||
hasUnlockedCapsule = true,
|
||||
weeklyRecapReady = true,
|
||||
categories = listOf(HomeCategorySummary(app.closer.domain.model.QuestionCategory(id = "c1"), 5))
|
||||
).withHomeActions()
|
||||
assertTrue("secondary capped at 3", out.secondaryActions.size <= 3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withHomeActions never duplicates the primary target in the pending list (C-HOME-001)`() {
|
||||
val out = HomeUiState(
|
||||
isLoading = false,
|
||||
isPaired = true,
|
||||
hasWaitingGame = true,
|
||||
hasActiveChallenge = true,
|
||||
hasUnlockedCapsule = true
|
||||
).withHomeActions()
|
||||
val primaryTarget = out.primaryAction?.target
|
||||
assertTrue(
|
||||
"primary target must not also appear in pending",
|
||||
out.pendingActions.none { it.target == primaryTarget }
|
||||
)
|
||||
assertTrue("pending capped at 3", out.pendingActions.size <= 3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withHomeActions produces no pending actions when unpaired`() {
|
||||
val out = HomeUiState(isLoading = false, isPaired = false).withHomeActions()
|
||||
assertTrue(out.pendingActions.isEmpty())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue