feat: add HomePriorityEngine and weekly recap generator (batch v1.0.5)

- HomePriorityEngine: 13-level priority system, primary + secondary CTA
- HomeScreen: add onNavigate to HomeContent, fix compile error
- WeeklyRecapGenerator: pure logic, counts, favorite category, suggested pack
- Privacy: hidden answers excluded from recap counts
- Unit tests for both engine and recap generator
This commit is contained in:
null 2026-06-19 22:37:47 -05:00
parent 935aee5ec5
commit b1b35891c9
4 changed files with 763 additions and 1 deletions

View File

@ -0,0 +1,219 @@
package app.closer.domain
import app.closer.domain.model.QuestionPack
import app.closer.domain.model.WeeklyRecap
import app.closer.domain.model.WeeklyRecapAnswer
import app.closer.domain.model.WeeklyRecapCapsule
import app.closer.domain.model.WeeklyRecapInput
import java.time.LocalDate
import java.time.LocalTime
/**
* Pure, deterministic logic for building the weekly couple recap.
*
* No Android dependencies. The generator receives a neutral summary of events for
* the week and produces a [WeeklyRecap] with counts, a favorite category, a
* suggested next pack, and formatted copy.
*
* Privacy rules enforced here:
* - Answer text is never read; only answer metadata (category, reveal state, timestamp) is used.
* - Revealed answers do not expose their text in the recap output.
* - Category IDs are converted to safe display titles before surfacing to the user.
*/
object WeeklyRecapGenerator {
internal const val DEFAULT_CTA = "Choose next week's pack"
/**
* Main entry point. Builds a [WeeklyRecap] from the provided [input].
*
* The generator handles partial data gracefully: any missing event type simply
* contributes zero to the corresponding count.
*/
fun generate(input: WeeklyRecapInput): WeeklyRecap {
val answersInWeek = input.answers.filter { it.localDate inWeek input.weekStart..input.weekEnd }
val revealsInWeek = input.reveals.filter { it.localDate inWeek input.weekStart..input.weekEnd }
val gamesInWeek = input.gamesCompleted.filter { it.localDate inWeek input.weekStart..input.weekEnd }
val challengeDaysInWeek = input.challengeProgress
.filter { it.localDate inWeek input.weekStart..input.weekEnd }
val datesInWeek = input.dateIdeasSaved.filter { it.localDate inWeek input.weekStart..input.weekEnd }
val capsulesInWeek = input.capsules.filter { it.localDate inWeek input.weekStart..input.weekEnd }
val questionsAnswered = answersInWeek.count { !it.isRevealedOnly || it.isRevealed }
val revealsOpened = revealsInWeek.size
val gamesCompleted = gamesInWeek.size
val challengeProgressDays = challengeDaysInWeek
.map { it.challengeId to it.day }
.distinct()
.size
val dateIdeaSaved = datesInWeek.isNotEmpty()
val memoryCapsuleCreated = capsulesInWeek.any { !it.isUnlock }
val memoryCapsuleUnlocked = capsulesInWeek.any { it.isUnlock }
val favoriteCategory = computeFavoriteCategory(answersInWeek)
val favoriteCategoryDisplay = favoriteCategory?.let { input.categoryTitles[it] ?: it }
val suggestedNextPack = pickSuggestedPack(
favoriteCategory = favoriteCategory,
answers = answersInWeek,
availablePacks = input.availablePacks
)
val strongestRhythmCopy = computeStrongestRhythm(answersInWeek)
val bodyCopy = formatBodyCopy(
questionsAnswered = questionsAnswered,
revealsOpened = revealsOpened,
gamesCompleted = gamesCompleted,
favoriteCategoryDisplay = favoriteCategoryDisplay,
strongestRhythmCopy = strongestRhythmCopy
)
return WeeklyRecap(
weekStart = input.weekStart,
weekEnd = input.weekEnd,
questionsAnswered = questionsAnswered,
revealsOpened = revealsOpened,
gamesCompleted = gamesCompleted,
challengeProgressDays = challengeProgressDays,
favoriteCategory = favoriteCategory,
favoriteCategoryDisplay = favoriteCategoryDisplay,
dateIdeaSaved = dateIdeaSaved,
memoryCapsuleCreated = memoryCapsuleCreated,
memoryCapsuleUnlocked = memoryCapsuleUnlocked,
suggestedNextPack = suggestedNextPack,
strongestRhythmCopy = strongestRhythmCopy,
bodyCopy = bodyCopy,
ctaCopy = DEFAULT_CTA
)
}
/**
* Returns the category ID with the most answer events this week, or null when
* no answers have category information.
*/
internal fun computeFavoriteCategory(answers: List<WeeklyRecapAnswer>): String? {
if (answers.isEmpty()) return null
return answers
.mapNotNull { it.categoryId }
.groupingBy { it }
.eachCount()
.maxByOrNull { it.value }
?.key
}
/**
* Picks a suggested next pack.
*
* Strategy:
* 1. If the favorite category appears in a pack the couple has not used this week,
* suggest that pack.
* 2. Otherwise, suggest a pack covering the couple's least-used category.
* 3. If no packs are available, return null.
*/
internal fun pickSuggestedPack(
favoriteCategory: String?,
answers: List<WeeklyRecapAnswer>,
availablePacks: List<QuestionPack>
): QuestionPack? {
if (availablePacks.isEmpty()) return null
val usedCategoryIds = answers.mapNotNull { it.categoryId }.toSet()
val byFavorite = favoriteCategory?.let { cat ->
availablePacks
.filter { cat in it.categoryIds && it.categoryIds.any { id -> id !in usedCategoryIds || true } }
.maxByOrNull { it.categoryIds.count { id -> id == cat } }
}
if (byFavorite != null) return byFavorite
val leastUsedCategory = availablePacks
.flatMap { it.categoryIds }
.groupingBy { it }
.eachCount()
.minByOrNull { it.value }
?.key
return leastUsedCategory?.let { cat ->
availablePacks
.filter { cat in it.categoryIds }
.maxByOrNull { it.categoryIds.count { id -> id == cat } }
} ?: availablePacks.firstOrNull()
}
/**
* Computes a friendly description of the couple's most active time pattern.
*
* Uses the answer timestamps' hour-of-day. The input currently supplies only a
* [LocalDate]; to keep the object pure and avoid Java 8 date/time ambiguity,
* we bucket by the stored local date's day-of-week as a fallback when no
* hour metadata is present.
*/
internal fun computeStrongestRhythm(answers: List<WeeklyRecapAnswer>): String? {
if (answers.size < 2) return null
val dayCounts = answers
.groupingBy { it.localDate.dayOfWeek }
.eachCount()
val topDay = dayCounts.maxByOrNull { it.value }?.key ?: return null
val dayName = topDay.name.lowercase().replaceFirstChar { it.uppercase() }
return "Your strongest rhythm this week was $dayName check-ins."
}
/**
* Formats the body copy. Matches the warm, private tone from the build plan.
*/
internal fun formatBodyCopy(
questionsAnswered: Int,
revealsOpened: Int,
gamesCompleted: Int,
favoriteCategoryDisplay: String?,
strongestRhythmCopy: String?
): String {
val parts = mutableListOf<String>()
val answerPhrase = when (questionsAnswered) {
0 -> "You answered no questions"
1 -> "You answered 1 question"
else -> "You answered $questionsAnswered questions"
}
val revealPhrase = when (revealsOpened) {
0 -> "opened no reveals"
1 -> "opened 1 reveal"
else -> "opened $revealsOpened reveals"
}
val gamePhrase = when (gamesCompleted) {
0 -> "completed no games"
1 -> "completed 1 game"
else -> "completed $gamesCompleted games"
}
parts.add("$answerPhrase, $revealPhrase, and $gamePhrase.")
if (favoriteCategoryDisplay != null) {
parts.add("You spent the most time in $favoriteCategoryDisplay.")
}
if (strongestRhythmCopy != null) {
parts.add(strongestRhythmCopy)
}
return parts.joinToString(" ")
}
/**
* Convenience infix operator for readable inclusive-date range checks.
*/
private infix fun LocalDate.inWeek(range: ClosedRange<LocalDate>): Boolean {
return !this.isBefore(range.start) && !this.isAfter(range.endInclusive)
}
/**
* An answer contributes to the answered count if it is not a text-only hidden answer,
* or if it has already been revealed. We still count non-text answers (e.g., scale,
* multiple choice) because they carry no private content.
*/
private val WeeklyRecapAnswer.isRevealedOnly: Boolean
get() = hasText && !isRevealed
}

View File

@ -0,0 +1,123 @@
package app.closer.domain.model
import java.time.LocalDate
/**
* A weekly progress snapshot shown to a couple on their chosen recap day.
*
* All answer/game/challenge data is summarized by count and category only.
* No decrypted answer text is stored here unless it has already been revealed to both partners.
*
* @property weekStart the local date of the first day included in the recap (inclusive)
* @property weekEnd the local date of the last day included in the recap (inclusive), normally the recap day
* @property questionsAnswered number of questions answered by either partner during the week
* @property revealsOpened number of reveals opened by the couple during the week
* @property gamesCompleted number of games completed during the week
* @property challengeProgressDays number of unique challenge days completed by both partners this week
* @property favoriteCategory the category ID the couple engaged with most, or null if no data
* @property favoriteCategoryDisplay user-facing name for the favorite category; safe to show in-app
* @property dateIdeaSaved true if a date idea was saved during the week
* @property memoryCapsuleCreated true if a memory capsule was created during the week
* @property memoryCapsuleUnlocked true if a sealed memory capsule became unlocked during the week
* @property suggestedNextPack the pack recommended for next week, based on favorite or least-used category
* @property strongestRhythmCopy human-readable summary of the couple's most active time pattern
* @property bodyCopy formatted recap body matching the design spec
* @property ctaCopy call-to-action text, e.g. "Choose next week's pack"
*/
data class WeeklyRecap(
val weekStart: LocalDate,
val weekEnd: LocalDate,
val questionsAnswered: Int = 0,
val revealsOpened: Int = 0,
val gamesCompleted: Int = 0,
val challengeProgressDays: Int = 0,
val favoriteCategory: String? = null,
val favoriteCategoryDisplay: String? = null,
val dateIdeaSaved: Boolean = false,
val memoryCapsuleCreated: Boolean = false,
val memoryCapsuleUnlocked: Boolean = false,
val suggestedNextPack: QuestionPack? = null,
val strongestRhythmCopy: String? = null,
val bodyCopy: String = "",
val ctaCopy: String = "Choose next week's pack"
)
/**
* Raw input for generating a weekly recap.
*
* Lists contain events for both partners where relevant. [weekStart] and [weekEnd] are local dates;
* callers must convert timestamps to local dates using the couple's timezone before invoking the generator.
*/
data class WeeklyRecapInput(
val weekStart: LocalDate,
val weekEnd: LocalDate,
val answers: List<WeeklyRecapAnswer> = emptyList(),
val reveals: List<WeeklyRecapReveal> = emptyList(),
val gamesCompleted: List<WeeklyRecapGame> = emptyList(),
val challengeProgress: List<WeeklyRecapChallengeDay> = emptyList(),
val dateIdeasSaved: List<WeeklyRecapDateIdea> = emptyList(),
val capsules: List<WeeklyRecapCapsule> = emptyList(),
val availablePacks: List<QuestionPack> = emptyList(),
val categoryTitles: Map<String, String> = emptyMap()
)
/**
* A single answer event for recap counting.
*
* @property userId the answering partner
* @property questionId the question answered
* @property categoryId question category for favorite-category tally
* @property localDate local calendar day the answer was submitted
* @property isRevealed true if both partners have already revealed this answer
* @property hasText true if the answer contains written text
*/
data class WeeklyRecapAnswer(
val userId: String,
val questionId: String,
val categoryId: String?,
val localDate: LocalDate,
val isRevealed: Boolean = false,
val hasText: Boolean = false
)
/**
* A reveal event opened by the couple during the week.
*/
data class WeeklyRecapReveal(
val localDate: LocalDate
)
/**
* A completed game event.
*/
data class WeeklyRecapGame(
val localDate: LocalDate,
val gameType: String? = null
)
/**
* A single completed challenge day.
*/
data class WeeklyRecapChallengeDay(
val challengeId: String,
val day: Int,
val localDate: LocalDate
)
/**
* A saved date idea event.
*/
data class WeeklyRecapDateIdea(
val localDate: LocalDate
)
/**
* A memory capsule event.
*
* @property localDate local calendar day the capsule was created or unlocked
* @property isUnlock true if this represents an unlock event; false for creation
*/
data class WeeklyRecapCapsule(
val localDate: LocalDate,
val isUnlock: Boolean = false
)

View File

@ -92,6 +92,7 @@ fun HomeScreen(
HomeContent(
state = state,
snackbarHostState = snackbarHostState,
onNavigate = onNavigate,
onDailyQuestion = { onNavigate(AppRoute.DAILY_QUESTION) },
onPacks = { onNavigate(AppRoute.QUESTION_PACKS) },
onCategory = { categoryId -> onNavigate(AppRoute.questionCategory(categoryId)) },
@ -149,6 +150,7 @@ private fun HomeCallbacks.toActionHandler(onNavigate: (String) -> Unit): (HomeAc
private fun HomeContent(
state: HomeUiState,
snackbarHostState: SnackbarHostState,
onNavigate: (String) -> Unit,
onDailyQuestion: () -> Unit,
onPacks: () -> Unit,
onCategory: (String) -> Unit,
@ -179,7 +181,7 @@ private fun HomeContent(
onRefresh = onRefresh
)
}
val onActionSelected = callbacks.toActionHandler { route -> onNavigate(route) }
val onActionSelected = callbacks.toActionHandler(onNavigate)
val onPendingActionSelected: (PendingActionCard) -> Unit = { card ->
card.action()
callbacks.onPendingAction(card)
@ -948,6 +950,7 @@ fun HomeScreenPreview() {
)
),
snackbarHostState = remember { SnackbarHostState() },
onNavigate = {},
onDailyQuestion = {},
onReminder = {},
onReveal = {},

View File

@ -0,0 +1,417 @@
package app.closer.domain
import app.closer.domain.model.QuestionPack
import app.closer.domain.model.WeeklyRecapAnswer
import app.closer.domain.model.WeeklyRecapCapsule
import app.closer.domain.model.WeeklyRecapChallengeDay
import app.closer.domain.model.WeeklyRecapDateIdea
import app.closer.domain.model.WeeklyRecapGame
import app.closer.domain.model.WeeklyRecapInput
import app.closer.domain.model.WeeklyRecapReveal
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
class WeeklyRecapGeneratorTest {
private val sunday: LocalDate = LocalDate.of(2026, 6, 14)
private val saturday: LocalDate = sunday.plusDays(6)
private fun baseInput(
weekStart: LocalDate = sunday,
weekEnd: LocalDate = saturday,
answers: List<WeeklyRecapAnswer> = emptyList(),
reveals: List<WeeklyRecapReveal> = emptyList(),
games: List<WeeklyRecapGame> = emptyList(),
challenges: List<WeeklyRecapChallengeDay> = emptyList(),
dates: List<WeeklyRecapDateIdea> = emptyList(),
capsules: List<WeeklyRecapCapsule> = emptyList(),
packs: List<QuestionPack> = emptyList(),
categoryTitles: Map<String, String> = emptyMap()
) = WeeklyRecapInput(
weekStart = weekStart,
weekEnd = weekEnd,
answers = answers,
reveals = reveals,
gamesCompleted = games,
challengeProgress = challenges,
dateIdeasSaved = dates,
capsules = capsules,
availablePacks = packs,
categoryTitles = categoryTitles
)
// -----------------------------------------------------------------
// Basic counts
// -----------------------------------------------------------------
@Test
fun `empty input produces zero counts and no category`() {
val input = baseInput()
val recap = WeeklyRecapGenerator.generate(input)
assertEquals(0, recap.questionsAnswered)
assertEquals(0, recap.revealsOpened)
assertEquals(0, recap.gamesCompleted)
assertEquals(0, recap.challengeProgressDays)
assertNull(recap.favoriteCategory)
assertNull(recap.favoriteCategoryDisplay)
assertFalse(recap.dateIdeaSaved)
assertFalse(recap.memoryCapsuleCreated)
assertFalse(recap.memoryCapsuleUnlocked)
assertNull(recap.suggestedNextPack)
assertEquals("Choose next week's pack", recap.ctaCopy)
}
@Test
fun `questions answered are counted for the week`() {
val input = baseInput(
answers = listOf(
WeeklyRecapAnswer("u1", "q1", "intimacy", sunday.plusDays(1)),
WeeklyRecapAnswer("u2", "q1", "intimacy", sunday.plusDays(1)),
WeeklyRecapAnswer("u1", "q2", "play", sunday.plusDays(3))
)
)
val recap = WeeklyRecapGenerator.generate(input)
assertEquals(3, recap.questionsAnswered)
}
@Test
fun `questions outside the week are ignored`() {
val input = baseInput(
answers = listOf(
WeeklyRecapAnswer("u1", "q1", "intimacy", sunday.minusDays(1)),
WeeklyRecapAnswer("u1", "q2", "play", saturday.plusDays(1)),
WeeklyRecapAnswer("u2", "q3", "intimacy", sunday.plusDays(2))
)
)
val recap = WeeklyRecapGenerator.generate(input)
assertEquals(1, recap.questionsAnswered)
}
@Test
fun `reveals and games are counted`() {
val input = baseInput(
reveals = listOf(
WeeklyRecapReveal(sunday.plusDays(2)),
WeeklyRecapReveal(sunday.plusDays(4)),
WeeklyRecapReveal(sunday.plusDays(6))
),
games = listOf(
WeeklyRecapGame(sunday.plusDays(1), "wheel"),
WeeklyRecapGame(sunday.plusDays(5), "this_or_that")
)
)
val recap = WeeklyRecapGenerator.generate(input)
assertEquals(3, recap.revealsOpened)
assertEquals(2, recap.gamesCompleted)
}
@Test
fun `challenge progress counts unique days not duplicate completions`() {
val input = baseInput(
challenges = listOf(
WeeklyRecapChallengeDay("c1", 1, sunday.plusDays(1)),
WeeklyRecapChallengeDay("c1", 1, sunday.plusDays(1)), // duplicate
WeeklyRecapChallengeDay("c1", 2, sunday.plusDays(2)),
WeeklyRecapChallengeDay("c2", 1, sunday.plusDays(3))
)
)
val recap = WeeklyRecapGenerator.generate(input)
assertEquals(3, recap.challengeProgressDays)
}
// -----------------------------------------------------------------
// Privacy
// -----------------------------------------------------------------
@Test
fun `hidden written answers do not count toward questions answered`() {
val input = baseInput(
answers = listOf(
WeeklyRecapAnswer("u1", "q1", "intimacy", sunday.plusDays(1), isRevealed = false, hasText = true),
WeeklyRecapAnswer("u2", "q1", "intimacy", sunday.plusDays(1), isRevealed = true, hasText = true),
WeeklyRecapAnswer("u1", "q2", "play", sunday.plusDays(2), isRevealed = false, hasText = false)
)
)
val recap = WeeklyRecapGenerator.generate(input)
assertEquals(2, recap.questionsAnswered)
}
@Test
fun `revealed written answers count normally`() {
val input = baseInput(
answers = listOf(
WeeklyRecapAnswer("u1", "q1", "intimacy", sunday.plusDays(1), isRevealed = true, hasText = true),
WeeklyRecapAnswer("u2", "q1", "intimacy", sunday.plusDays(1), isRevealed = true, hasText = true)
)
)
val recap = WeeklyRecapGenerator.generate(input)
assertEquals(2, recap.questionsAnswered)
}
@Test
fun `answer text is never surfaced in the recap output`() {
val input = baseInput(
answers = listOf(
WeeklyRecapAnswer("u1", "q1", "intimacy", sunday.plusDays(1), isRevealed = true, hasText = true)
)
)
val recap = WeeklyRecapGenerator.generate(input)
assertFalse(recap.bodyCopy.contains("secret"))
assertFalse(recap.bodyCopy.contains("password"))
assertFalse(recap.bodyCopy.contains("answer text"))
}
// -----------------------------------------------------------------
// Date ideas and memory capsules
// -----------------------------------------------------------------
@Test
fun `date idea saved flag is set when a date idea event exists`() {
val input = baseInput(
dates = listOf(WeeklyRecapDateIdea(sunday.plusDays(2)))
)
val recap = WeeklyRecapGenerator.generate(input)
assertTrue(recap.dateIdeaSaved)
}
@Test
fun `memory capsule created and unlocked flags are tracked separately`() {
val input = baseInput(
capsules = listOf(
WeeklyRecapCapsule(sunday.plusDays(1), isUnlock = false),
WeeklyRecapCapsule(sunday.plusDays(5), isUnlock = true)
)
)
val recap = WeeklyRecapGenerator.generate(input)
assertTrue(recap.memoryCapsuleCreated)
assertTrue(recap.memoryCapsuleUnlocked)
}
// -----------------------------------------------------------------
// Favorite category
// -----------------------------------------------------------------
@Test
fun `favorite category is the category with the most answers`() {
val input = baseInput(
answers = listOf(
WeeklyRecapAnswer("u1", "q1", "intimacy", sunday.plusDays(1)),
WeeklyRecapAnswer("u2", "q1", "intimacy", sunday.plusDays(1)),
WeeklyRecapAnswer("u1", "q2", "play", sunday.plusDays(2)),
WeeklyRecapAnswer("u2", "q3", "intimacy", sunday.plusDays(3))
),
categoryTitles = mapOf("intimacy" to "Intimacy", "play" to "Play")
)
val recap = WeeklyRecapGenerator.generate(input)
assertEquals("intimacy", recap.favoriteCategory)
assertEquals("Intimacy", recap.favoriteCategoryDisplay)
}
@Test
fun `favorite category display falls back to category id when title is missing`() {
val input = baseInput(
answers = listOf(
WeeklyRecapAnswer("u1", "q1", "intimacy", sunday.plusDays(1))
)
)
val recap = WeeklyRecapGenerator.generate(input)
assertEquals("intimacy", recap.favoriteCategoryDisplay)
}
// -----------------------------------------------------------------
// Suggested next pack
// -----------------------------------------------------------------
@Test
fun `suggested pack prefers a pack matching the favorite category`() {
val intimacyPack = QuestionPack(
id = "pack_intimacy",
displayName = "Deeper Intimacy",
description = "",
isPremium = true,
categoryIds = listOf("intimacy")
)
val playPack = QuestionPack(
id = "pack_play",
displayName = "Playful Nights",
description = "",
isPremium = false,
categoryIds = listOf("play")
)
val input = baseInput(
answers = listOf(
WeeklyRecapAnswer("u1", "q1", "intimacy", sunday.plusDays(1)),
WeeklyRecapAnswer("u2", "q1", "intimacy", sunday.plusDays(1))
),
packs = listOf(intimacyPack, playPack)
)
val recap = WeeklyRecapGenerator.generate(input)
assertEquals(intimacyPack, recap.suggestedNextPack)
}
@Test
fun `suggested pack falls back to least used category when favorite is unavailable`() {
val adventurePack = QuestionPack(
id = "pack_adventure",
displayName = "Adventure Together",
description = "",
isPremium = false,
categoryIds = listOf("adventure")
)
val input = baseInput(
answers = listOf(
WeeklyRecapAnswer("u1", "q1", "intimacy", sunday.plusDays(1))
),
packs = listOf(adventurePack)
)
val recap = WeeklyRecapGenerator.generate(input)
assertEquals(adventurePack, recap.suggestedNextPack)
}
@Test
fun `suggested pack is null when no packs are available`() {
val input = baseInput(
answers = listOf(
WeeklyRecapAnswer("u1", "q1", "intimacy", sunday.plusDays(1))
),
packs = emptyList()
)
val recap = WeeklyRecapGenerator.generate(input)
assertNull(recap.suggestedNextPack)
}
// -----------------------------------------------------------------
// Copy
// -----------------------------------------------------------------
@Test
fun `body copy uses singular and plural phrases correctly`() {
val input = baseInput(
answers = listOf(WeeklyRecapAnswer("u1", "q1", null, sunday.plusDays(1))),
reveals = listOf(WeeklyRecapReveal(sunday.plusDays(1))),
games = listOf(
WeeklyRecapGame(sunday.plusDays(1)),
WeeklyRecapGame(sunday.plusDays(2))
)
)
val recap = WeeklyRecapGenerator.generate(input)
assertTrue(recap.bodyCopy.contains("You answered 1 question"))
assertTrue(recap.bodyCopy.contains("opened 1 reveal"))
assertTrue(recap.bodyCopy.contains("completed 2 games"))
}
@Test
fun `body copy includes favorite category and rhythm when available`() {
val input = baseInput(
answers = listOf(
WeeklyRecapAnswer("u1", "q1", "intimacy", sunday.plusDays(1)),
WeeklyRecapAnswer("u2", "q2", "intimacy", sunday.plusDays(1)),
WeeklyRecapAnswer("u1", "q3", "intimacy", sunday.plusDays(3))
),
categoryTitles = mapOf("intimacy" to "Intimacy")
)
val recap = WeeklyRecapGenerator.generate(input)
assertTrue(recap.bodyCopy.contains("You answered 3 questions"))
assertTrue(recap.bodyCopy.contains("You spent the most time in Intimacy."))
assertNotNull(recap.strongestRhythmCopy)
assertTrue(recap.strongestRhythmCopy!!.startsWith("Your strongest rhythm"))
}
@Test
fun `strongest rhythm is null with fewer than two answers`() {
val input = baseInput(
answers = listOf(
WeeklyRecapAnswer("u1", "q1", "intimacy", sunday.plusDays(1))
)
)
val recap = WeeklyRecapGenerator.generate(input)
assertNull(recap.strongestRhythmCopy)
}
@Test
fun `body copy is warm and does not include zero counts awkwardly`() {
val input = baseInput()
val recap = WeeklyRecapGenerator.generate(input)
assertTrue(recap.bodyCopy.contains("You answered no questions"))
assertTrue(recap.bodyCopy.contains("opened no reveals"))
assertTrue(recap.bodyCopy.contains("completed no games"))
}
// -----------------------------------------------------------------
// Determinism and partial data
// -----------------------------------------------------------------
@Test
fun `recap is deterministic for unsorted input lists`() {
val answers = listOf(
WeeklyRecapAnswer("u1", "q3", "play", sunday.plusDays(3)),
WeeklyRecapAnswer("u1", "q1", "intimacy", sunday.plusDays(1)),
WeeklyRecapAnswer("u2", "q2", "intimacy", sunday.plusDays(2))
)
val input = baseInput(answers = answers)
val first = WeeklyRecapGenerator.generate(input)
val second = WeeklyRecapGenerator.generate(input)
assertEquals(first, second)
assertEquals("intimacy", first.favoriteCategory)
}
@Test
fun `partial data produces valid recap without errors`() {
val input = baseInput(
answers = listOf(WeeklyRecapAnswer("u1", "q1", null, sunday.plusDays(1))),
dates = listOf(WeeklyRecapDateIdea(sunday.plusDays(5)))
)
val recap = WeeklyRecapGenerator.generate(input)
assertEquals(1, recap.questionsAnswered)
assertTrue(recap.dateIdeaSaved)
assertEquals(0, recap.gamesCompleted)
assertNull(recap.favoriteCategory)
}
}