diff --git a/app/src/main/java/app/closer/domain/WeeklyRecapGenerator.kt b/app/src/main/java/app/closer/domain/WeeklyRecapGenerator.kt new file mode 100644 index 00000000..70cf5d2f --- /dev/null +++ b/app/src/main/java/app/closer/domain/WeeklyRecapGenerator.kt @@ -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): 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, + availablePacks: List + ): 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): 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() + + 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): 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 +} diff --git a/app/src/main/java/app/closer/domain/model/WeeklyRecap.kt b/app/src/main/java/app/closer/domain/model/WeeklyRecap.kt new file mode 100644 index 00000000..b1694814 --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/WeeklyRecap.kt @@ -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 = emptyList(), + val reveals: List = emptyList(), + val gamesCompleted: List = emptyList(), + val challengeProgress: List = emptyList(), + val dateIdeasSaved: List = emptyList(), + val capsules: List = emptyList(), + val availablePacks: List = emptyList(), + val categoryTitles: Map = 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 +) diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index 433bb691..7707639c 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -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 = {}, diff --git a/app/src/test/java/app/closer/domain/WeeklyRecapGeneratorTest.kt b/app/src/test/java/app/closer/domain/WeeklyRecapGeneratorTest.kt new file mode 100644 index 00000000..c88ab0b6 --- /dev/null +++ b/app/src/test/java/app/closer/domain/WeeklyRecapGeneratorTest.kt @@ -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 = emptyList(), + reveals: List = emptyList(), + games: List = emptyList(), + challenges: List = emptyList(), + dates: List = emptyList(), + capsules: List = emptyList(), + packs: List = emptyList(), + categoryTitles: Map = 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) + } +}