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:
parent
935aee5ec5
commit
b1b35891c9
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue