diff --git a/app/src/androidTest/java/app/closer/ui/home/HomeContentRenderSmokeTest.kt b/app/src/androidTest/java/app/closer/ui/home/HomeContentRenderSmokeTest.kt new file mode 100644 index 00000000..3d72cd28 --- /dev/null +++ b/app/src/androidTest/java/app/closer/ui/home/HomeContentRenderSmokeTest.kt @@ -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() + } +} diff --git a/app/src/test/java/app/closer/ui/home/HomeActionMapperTest.kt b/app/src/test/java/app/closer/ui/home/HomeActionMapperTest.kt new file mode 100644 index 00000000..3b26c778 --- /dev/null +++ b/app/src/test/java/app/closer/ui/home/HomeActionMapperTest.kt @@ -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()) + } +}