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