feat(home): HomePriorityEngine priority logic, HomeViewModel wiring, unit test coverage

This commit is contained in:
null 2026-06-30 01:26:09 -05:00
parent 2a5c40508e
commit 941f22cdbd
3 changed files with 105 additions and 0 deletions

View File

@ -44,6 +44,8 @@ object HomePriorityEngine {
val gameWaiting: Boolean = false, val gameWaiting: Boolean = false,
val challengeWaiting: Boolean = false, val challengeWaiting: Boolean = false,
val dailyQuestionUnanswered: Boolean = false, val dailyQuestionUnanswered: Boolean = false,
val dailyQuestionAwaitingPartner: Boolean = false,
val dailyQuestionRevealed: Boolean = false,
val weeklyRecapReady: Boolean = false, val weeklyRecapReady: Boolean = false,
val capsuleUnlocked: Boolean = false, val capsuleUnlocked: Boolean = false,
val dateReminder: Boolean = false, val dateReminder: Boolean = false,
@ -60,9 +62,14 @@ object HomePriorityEngine {
GAME_WAITING, GAME_WAITING,
DAILY_QUESTION_UNANSWERED, DAILY_QUESTION_UNANSWERED,
CHALLENGE_WAITING, CHALLENGE_WAITING,
// You answered; waiting on your partner. Ritual band: below actionable games/challenges,
// above reflective/generic content. Becomes the hero only when nothing more urgent is active.
DAILY_QUESTION_AWAITING_PARTNER,
WEEKLY_RECAP_READY, WEEKLY_RECAP_READY,
CAPSULE_UNLOCKED, CAPSULE_UNLOCKED,
DATE_REMINDER, DATE_REMINDER,
// You already revealed today's question — a low-priority "keep the conversation going" closure card.
DAILY_QUESTION_REVEALED,
SUGGESTED_PACK, SUGGESTED_PACK,
EXPLORE_GAMES EXPLORE_GAMES
} }
@ -129,6 +136,8 @@ object HomePriorityEngine {
Priority.GAME_WAITING -> input.gameWaiting Priority.GAME_WAITING -> input.gameWaiting
Priority.CHALLENGE_WAITING -> input.challengeWaiting Priority.CHALLENGE_WAITING -> input.challengeWaiting
Priority.DAILY_QUESTION_UNANSWERED -> input.dailyQuestionUnanswered Priority.DAILY_QUESTION_UNANSWERED -> input.dailyQuestionUnanswered
Priority.DAILY_QUESTION_AWAITING_PARTNER -> input.dailyQuestionAwaitingPartner
Priority.DAILY_QUESTION_REVEALED -> input.dailyQuestionRevealed
Priority.WEEKLY_RECAP_READY -> input.weeklyRecapReady Priority.WEEKLY_RECAP_READY -> input.weeklyRecapReady
Priority.CAPSULE_UNLOCKED -> input.capsuleUnlocked Priority.CAPSULE_UNLOCKED -> input.capsuleUnlocked
Priority.DATE_REMINDER -> input.dateReminder Priority.DATE_REMINDER -> input.dateReminder
@ -154,6 +163,8 @@ object HomePriorityEngine {
*/ */
private fun Priority.isValueAction(): Boolean = when (this) { private fun Priority.isValueAction(): Boolean = when (this) {
Priority.DAILY_QUESTION_UNANSWERED, Priority.DAILY_QUESTION_UNANSWERED,
Priority.DAILY_QUESTION_AWAITING_PARTNER,
Priority.DAILY_QUESTION_REVEALED,
Priority.WEEKLY_RECAP_READY, Priority.WEEKLY_RECAP_READY,
Priority.DATE_REMINDER -> true Priority.DATE_REMINDER -> true
else -> false else -> false

View File

@ -567,6 +567,8 @@ class HomeViewModel @Inject constructor(
gameWaiting = hasWaitingGame(), gameWaiting = hasWaitingGame(),
challengeWaiting = hasIncompleteChallenge(), challengeWaiting = hasIncompleteChallenge(),
dailyQuestionUnanswered = dailyQuestionState == DailyQuestionState.UNANSWERED && dailyQuestion != null, dailyQuestionUnanswered = dailyQuestionState == DailyQuestionState.UNANSWERED && dailyQuestion != null,
dailyQuestionAwaitingPartner = dailyQuestionState == DailyQuestionState.USER_ANSWERED_PARTNER_PENDING,
dailyQuestionRevealed = dailyQuestionState == DailyQuestionState.REVEALED && dailyQuestion != null,
weeklyRecapReady = weeklyRecapReady, weeklyRecapReady = weeklyRecapReady,
capsuleUnlocked = hasUnlockedCapsule(), capsuleUnlocked = hasUnlockedCapsule(),
dateReminder = hasUpcomingDate(), dateReminder = hasUpcomingDate(),
@ -666,6 +668,23 @@ class HomeViewModel @Inject constructor(
cta = "Answer privately" cta = "Answer privately"
) )
// You answered; the reveal waits on your partner. The hero card (PrimaryHomeActionCard) overrides
// this title/body and routes the CTA to the gentle-reminder send; this copy/CTA label is what shows
// when it renders as a smaller secondary card (a game/challenge is the hero).
Priority.DAILY_QUESTION_AWAITING_PARTNER -> buildDailyQuestionAction(
title = "You showed up tonight.",
body = partnerName?.let { "Your answer stays private until $it answers too — no pressure." }
?: "Your answer stays private until your partner answers too — no pressure.",
cta = "Send a gentle nudge"
)
// You already revealed today — a low-priority closure card that links to the discussion thread.
Priority.DAILY_QUESTION_REVEALED -> buildDailyQuestionAction(
title = "You opened a conversation tonight.",
body = "Keep it going whenever you're both ready.",
cta = "Keep the conversation going"
)
Priority.WEEKLY_RECAP_READY -> HomeAction( Priority.WEEKLY_RECAP_READY -> HomeAction(
eyebrow = "Your week together", eyebrow = "Your week together",
title = "Look back at what you built this week.", title = "Look back at what you built this week.",

View File

@ -4,6 +4,7 @@ import app.closer.ui.home.HomePriorityEngine.Input
import app.closer.ui.home.HomePriorityEngine.Priority import app.closer.ui.home.HomePriorityEngine.Priority
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class HomePriorityEngineTest { class HomePriorityEngineTest {
@ -122,6 +123,80 @@ class HomePriorityEngineTest {
assertEquals(Priority.DAILY_QUESTION_UNANSWERED, output.primary?.priority) assertEquals(Priority.DAILY_QUESTION_UNANSWERED, output.primary?.priority)
} }
@Test
fun `awaiting partner is primary when no blockers`() {
val input = Input(
isPaired = true,
dailyQuestionAwaitingPartner = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.DAILY_QUESTION_AWAITING_PARTNER, output.primary?.priority)
}
@Test
fun `game and challenge waiting outrank awaiting partner, which drops to a secondary card`() {
val input = Input(
isPaired = true,
gameWaiting = true,
challengeWaiting = true,
dailyQuestionAwaitingPartner = true
)
val output = HomePriorityEngine.compute(input)
// Ritual band: an actionable waiting game is the hero; "you answered, waiting" still shows below.
assertEquals(Priority.GAME_WAITING, output.primary?.priority)
assertTrue(output.secondary.any { it.priority == Priority.DAILY_QUESTION_AWAITING_PARTNER })
}
@Test
fun `awaiting partner outranks weekly recap`() {
val input = Input(
isPaired = true,
dailyQuestionAwaitingPartner = true,
weeklyRecapReady = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.DAILY_QUESTION_AWAITING_PARTNER, output.primary?.priority)
}
@Test
fun `revealed closure ranks below date reminder but surfaces as a secondary value card`() {
val input = Input(
isPaired = true,
dateReminder = true,
dailyQuestionRevealed = true,
suggestedPackAvailable = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.DATE_REMINDER, output.primary?.priority)
// Revealed is a value action (kept); the generic suggested pack is filtered out of secondary.
assertEquals(
listOf(Priority.DAILY_QUESTION_REVEALED),
output.secondary.map { it.priority }
)
}
@Test
fun `revealed closure outranks generic browse content`() {
val input = Input(
isPaired = true,
dailyQuestionRevealed = true,
suggestedPackAvailable = true,
exploreGamesAvailable = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.DAILY_QUESTION_REVEALED, output.primary?.priority)
}
@Test @Test
fun `weekly recap outranks capsule and date reminder`() { fun `weekly recap outranks capsule and date reminder`() {
val input = Input( val input = Input(