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:
null 2026-07-11 20:46:16 -05:00
parent 9e81c15350
commit f7eb03417b
2 changed files with 214 additions and 0 deletions

View File

@ -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()
}
}

View File

@ -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())
}
}