diff --git a/app/src/main/java/app/closer/domain/DateSuggestionEngine.kt b/app/src/main/java/app/closer/domain/DateSuggestionEngine.kt new file mode 100644 index 00000000..a6d9daf3 --- /dev/null +++ b/app/src/main/java/app/closer/domain/DateSuggestionEngine.kt @@ -0,0 +1,287 @@ +package app.closer.domain + +import app.closer.domain.model.DateCostLevel +import app.closer.domain.model.DateSuggestion +import app.closer.domain.model.DateSuggestionTrigger +import app.closer.domain.model.SourceFlag + +/** + * Pure, deterministic logic for deciding when to suggest a date idea. + * + * No Android dependencies. The engine receives a neutral summary of recent couple activity + * and returns a prioritized list of [DateSuggestion]s. Callers (e.g., a ViewModel or Home + * priority engine) decide whether to show, queue, or dismiss each suggestion. + * + * Privacy rules enforced here: + * - Hidden private answer text is never read or surfaced. + * - Revealed answers may influence suggestions but are explicitly flagged as + * [SourceFlag.REVEALED_ANSWER]; the original text is not included in the output. + * - Notification copy and suggestion text use only safe category metadata and + * curated activity phrases. + */ +object DateSuggestionEngine { + + /** + * Number of answers in a single category required to trigger a category-surge suggestion. + */ + internal const val CATEGORY_SURGE_THRESHOLD = 3 + + /** + * Suggests dates based on the provided [input]. + * + * The returned list is sorted by descending priority. Callers should normally show the + * highest-priority suggestion and suppress duplicates for the same trigger/category pair. + * + * @param input a snapshot of recent couple activity + * @return zero or more date suggestions, highest priority first + */ + fun suggest(input: DateSuggestionInput): List { + val suggestions = mutableListOf() + + suggestions += categorySurgeSuggestions(input) + suggestions += recapCompletionSuggestion(input) + suggestions += challengeCompletionSuggestion(input) + suggestions += partnerSavedDateSuggestion(input) + suggestions += sharedInterestRevealSuggestion(input) + + return suggestions.sortedByDescending { it.priority } + } + + // ----------------------------------------------------------------- + // Trigger: 3 answered questions in one category + // ----------------------------------------------------------------- + + internal fun categorySurgeSuggestions(input: DateSuggestionInput): List { + if (input.answeredQuestionsByCategory.isEmpty()) return emptyList() + + val categoryCounts = input.answeredQuestionsByCategory + .groupingBy { it.categoryId } + .eachCount() + .filterValues { it >= CATEGORY_SURGE_THRESHOLD } + .toList() + .sortedByDescending { (_, count) -> count } + + return categoryCounts.map { (categoryId, count) -> + val activity = activityForCategory( + categoryId = categoryId, + titleCatalog = input.dateIdeaTitleCatalog, + fallback = "A small date idea based on what you've explored together" + ) + val costLevel = costLevelForCategory(categoryId) + val duration = durationForCategory(categoryId) + DateSuggestion( + triggerReason = DateSuggestionTrigger.CATEGORY_SURGE, + suggestedActivity = "$activity ($duration · ${costLabel(costLevel)})", + priority = count.coerceAtLeast(CATEGORY_SURGE_THRESHOLD) * 10, + categoryId = categoryId, + sourceFlags = listOf(SourceFlag.CATEGORY_METADATA) + ) + } + } + + // ----------------------------------------------------------------- + // Trigger: completed weekly recap + // ----------------------------------------------------------------- + + internal fun recapCompletionSuggestion(input: DateSuggestionInput): List { + if (!input.weeklyRecapCompleted) return emptyList() + + val favoriteCategory = input.weeklyRecapFavoriteCategory + val activity = activityForCategory( + categoryId = favoriteCategory, + titleCatalog = input.dateIdeaTitleCatalog, + fallback = "Plan something together this week" + ) + val costLevel = costLevelForCategory(favoriteCategory) + val duration = durationForCategory(favoriteCategory) + + return listOf( + DateSuggestion( + triggerReason = DateSuggestionTrigger.WEEKLY_RECAP_COMPLETED, + suggestedActivity = "$activity ($duration · ${costLabel(costLevel)})", + priority = 35, + categoryId = favoriteCategory, + sourceFlags = listOf(SourceFlag.RECAP_CONTEXT, SourceFlag.CATEGORY_METADATA) + ) + ) + } + + // ----------------------------------------------------------------- + // Trigger: completed challenge + // ----------------------------------------------------------------- + + internal fun challengeCompletionSuggestion(input: DateSuggestionInput): List { + if (!input.challengeCompleted) return emptyList() + + val challengeCategory = input.completedChallengeCategory + val activity = activityForCategory( + categoryId = challengeCategory, + titleCatalog = input.dateIdeaTitleCatalog, + fallback = "Celebrate finishing your challenge with a date" + ) + val costLevel = costLevelForCategory(challengeCategory) + val duration = durationForCategory(challengeCategory) + + return listOf( + DateSuggestion( + triggerReason = DateSuggestionTrigger.CHALLENGE_COMPLETED, + suggestedActivity = "$activity ($duration · ${costLabel(costLevel)})", + priority = 40, + categoryId = challengeCategory, + sourceFlags = listOf(SourceFlag.CHALLENGE_CONTEXT, SourceFlag.CATEGORY_METADATA) + ) + ) + } + + // ----------------------------------------------------------------- + // Trigger: a partner saves a date idea + // ----------------------------------------------------------------- + + internal fun partnerSavedDateSuggestion(input: DateSuggestionInput): List { + if (input.partnerSavedDateIdeaIds.isEmpty()) return emptyList() + + return input.partnerSavedDateIdeaIds.mapIndexed { index, ideaId -> + val title = input.dateIdeaTitleCatalog[ideaId] + ?: "A date idea your partner saved" + val categoryId = input.dateIdeaCategoryById[ideaId] + val costLevel = costLevelForCategory(categoryId) + val duration = durationForCategory(categoryId) + DateSuggestion( + triggerReason = DateSuggestionTrigger.PARTNER_SAVED_DATE_IDEA, + suggestedActivity = "$title ($duration · ${costLabel(costLevel)})", + priority = 45 - index.coerceAtLeast(0).coerceAtMost(5) * 3, + categoryId = categoryId, + sourceFlags = listOf(SourceFlag.PARTNER_ACTION) + ) + } + } + + // ----------------------------------------------------------------- + // Trigger: reveal indicates shared interest, if already revealed + // ----------------------------------------------------------------- + + internal fun sharedInterestRevealSuggestion(input: DateSuggestionInput): List { + if (!input.hasSharedInterestReveal) return emptyList() + + val categoryId = input.sharedInterestCategory + val activity = activityForCategory( + categoryId = categoryId, + titleCatalog = input.dateIdeaTitleCatalog, + fallback = "A date idea inspired by something you both revealed" + ) + val costLevel = costLevelForCategory(categoryId) + val duration = durationForCategory(categoryId) + + return listOf( + DateSuggestion( + triggerReason = DateSuggestionTrigger.SHARED_INTEREST_REVEALED, + suggestedActivity = "$activity ($duration · ${costLabel(costLevel)})", + priority = 50, + categoryId = categoryId, + sourceFlags = listOf(SourceFlag.REVEALED_ANSWER, SourceFlag.CATEGORY_METADATA) + ) + ) + } + + // ----------------------------------------------------------------- + // Category-to-activity mapping + // ----------------------------------------------------------------- + + /** + * Returns a curated activity phrase for [categoryId]. If a caller-supplied catalog + * contains a title keyed by [categoryId], that title is used; otherwise a safe default + * phrase is chosen from the built-in map. [fallback] is used only when no category is given. + */ + internal fun activityForCategory( + categoryId: String?, + titleCatalog: Map, + fallback: String + ): String { + if (categoryId.isNullOrBlank()) return fallback + return titleCatalog[categoryId] ?: defaultActivityForCategory(categoryId) + } + + internal fun defaultActivityForCategory(categoryId: String): String = when (categoryId.lowercase()) { + "adventure" -> "Try a new outdoor experience together" + "travel" -> "Plan a mini local adventure" + "food" -> "Cook or taste something new together" + "learning" -> "Learn something new as a pair" + "romance" -> "A quiet, intentional evening together" + "intimacy" -> "A private, connected moment together" + "connection" -> "A low-pressure way to check in over something fun" + "seasonal" -> "A seasonal date that fits this time of year" + "play" -> "A playful, lighthearted activity together" + else -> "A small date idea based on what you've explored together" + } + + internal fun costLevelForCategory(categoryId: String?): DateCostLevel = when (categoryId?.lowercase()) { + "adventure", "travel" -> DateCostLevel.LOW + "food" -> DateCostLevel.MEDIUM + "learning" -> DateCostLevel.LOW + "romance", "intimacy" -> DateCostLevel.LOW + "connection", "play" -> DateCostLevel.FREE + "seasonal" -> DateCostLevel.LOW + else -> DateCostLevel.FREE + } + + internal fun durationForCategory(categoryId: String?): String = when (categoryId?.lowercase()) { + "adventure", "travel" -> "2–3 hours" + "food" -> "1–2 hours" + "learning" -> "1–2 hours" + "romance", "intimacy" -> "1–2 hours" + "connection", "play" -> "30 min" + "seasonal" -> "1–2 hours" + else -> "30 min" + } + + internal fun costLabel(costLevel: DateCostLevel): String = when (costLevel) { + DateCostLevel.FREE -> "free" + DateCostLevel.LOW -> "low cost" + DateCostLevel.MEDIUM -> "moderate" + DateCostLevel.HIGH -> "higher cost" + } +} + +/** + * Neutral input snapshot for [DateSuggestionEngine]. + * + * All fields are metadata; no hidden answer text is present. Revealed answers are represented + * only by boolean flags and category identifiers. + * + * @property answeredQuestionsByCategory list of questions answered by the couple; each entry + * carries only the category ID and whether it has been revealed + * @property weeklyRecapCompleted true if the couple completed a weekly recap in this session + * @property weeklyRecapFavoriteCategory the favorite category ID from the recap, or null + * @property challengeCompleted true if a challenge was just completed + * @property completedChallengeCategory category ID of the completed challenge, or null + * @property partnerSavedDateIdeaIds IDs of date ideas the partner recently saved + * @property dateIdeaTitleCatalog caller-provided map of idea/category IDs to safe display titles + * @property dateIdeaCategoryById map of saved date idea IDs to their category IDs + * @property hasSharedInterestReveal true if a reveal surfaced a shared interest + * @property sharedInterestCategory category ID of the shared interest, or null + */ +data class DateSuggestionInput( + val answeredQuestionsByCategory: List = emptyList(), + val weeklyRecapCompleted: Boolean = false, + val weeklyRecapFavoriteCategory: String? = null, + val challengeCompleted: Boolean = false, + val completedChallengeCategory: String? = null, + val partnerSavedDateIdeaIds: List = emptyList(), + val dateIdeaTitleCatalog: Map = emptyMap(), + val dateIdeaCategoryById: Map = emptyMap(), + val hasSharedInterestReveal: Boolean = false, + val sharedInterestCategory: String? = null +) + +/** + * A lightweight reference to an answered question. + * + * @property categoryId question category used for category-surge detection + * @property isRevealed true if both partners have already revealed this answer. + * The engine never reads answer text, but callers may use this flag to decide + * whether revealed-answer metadata can inform a shared-interest suggestion. + */ +data class AnsweredQuestionRef( + val categoryId: String, + val isRevealed: Boolean = false +) diff --git a/app/src/main/java/app/closer/domain/model/DateSuggestion.kt b/app/src/main/java/app/closer/domain/model/DateSuggestion.kt new file mode 100644 index 00000000..9f32929c --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/DateSuggestion.kt @@ -0,0 +1,46 @@ +package app.closer.domain.model + +/** + * A generated date suggestion surfaced to a couple at a useful moment. + * + * This model carries only metadata: what activity to suggest, why it was triggered, + * and how strongly it should be prioritized. It never contains private answer text. + * + * @property triggerReason why the suggestion was generated + * @property suggestedActivity human-readable activity suggestion + * @property priority higher numbers are more urgent/interesting + * @property categoryId optional question/date category that informed the suggestion + * @property sourceFlags bitmap describing which inputs influenced this suggestion; + * revealed answers are explicitly tagged as [SourceFlag.REVEALED_ANSWER] + * and hidden answer text is never used + */ +data class DateSuggestion( + val triggerReason: DateSuggestionTrigger, + val suggestedActivity: String, + val priority: Int, + val categoryId: String? = null, + val sourceFlags: List = emptyList() +) { + /** + * True when the suggestion was informed by an already-revealed answer. + * Used to drive privacy-safe copy and analytics. + */ + val isFromRevealedAnswer: Boolean + get() = SourceFlag.REVEALED_ANSWER in sourceFlags +} + +enum class DateSuggestionTrigger { + CATEGORY_SURGE, + WEEKLY_RECAP_COMPLETED, + CHALLENGE_COMPLETED, + PARTNER_SAVED_DATE_IDEA, + SHARED_INTEREST_REVEALED +} + +enum class SourceFlag { + CATEGORY_METADATA, + REVEALED_ANSWER, + PARTNER_ACTION, + CHALLENGE_CONTEXT, + RECAP_CONTEXT +} diff --git a/app/src/test/java/app/closer/domain/DateSuggestionEngineTest.kt b/app/src/test/java/app/closer/domain/DateSuggestionEngineTest.kt new file mode 100644 index 00000000..08bf1868 --- /dev/null +++ b/app/src/test/java/app/closer/domain/DateSuggestionEngineTest.kt @@ -0,0 +1,403 @@ +package app.closer.domain + +import app.closer.domain.model.DateCostLevel +import app.closer.domain.model.DateSuggestionTrigger +import app.closer.domain.model.SourceFlag +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DateSuggestionEngineTest { + + // ----------------------------------------------------------------- + // Trigger: 3 answered questions in one category + // ----------------------------------------------------------------- + + @Test + fun `category surge triggered after three answers in same category`() { + val input = DateSuggestionInput( + answeredQuestionsByCategory = listOf( + AnsweredQuestionRef("food"), + AnsweredQuestionRef("food"), + AnsweredQuestionRef("food") + ) + ) + + val result = DateSuggestionEngine.suggest(input) + + assertEquals(1, result.size) + val suggestion = result.first() + assertEquals(DateSuggestionTrigger.CATEGORY_SURGE, suggestion.triggerReason) + assertEquals("food", suggestion.categoryId) + assertTrue(SourceFlag.CATEGORY_METADATA in suggestion.sourceFlags) + assertEquals(30, suggestion.priority) + assertTrue(suggestion.suggestedActivity.contains("Cook or taste something new together")) + } + + @Test + fun `category surge not triggered with only two answers`() { + val input = DateSuggestionInput( + answeredQuestionsByCategory = listOf( + AnsweredQuestionRef("food"), + AnsweredQuestionRef("food") + ) + ) + + val result = DateSuggestionEngine.suggest(input) + + assertTrue(result.isEmpty()) + } + + @Test + fun `category surge picks highest count category`() { + val input = DateSuggestionInput( + answeredQuestionsByCategory = listOf( + AnsweredQuestionRef("food"), + AnsweredQuestionRef("food"), + AnsweredQuestionRef("food"), + AnsweredQuestionRef("play"), + AnsweredQuestionRef("play") + ) + ) + + val result = DateSuggestionEngine.suggest(input) + + assertEquals(1, result.size) + assertEquals("food", result.first().categoryId) + } + + @Test + fun `category surge uses caller supplied title catalog`() { + val input = DateSuggestionInput( + answeredQuestionsByCategory = listOf( + AnsweredQuestionRef("food"), + AnsweredQuestionRef("food"), + AnsweredQuestionRef("food") + ), + dateIdeaTitleCatalog = mapOf("food" to "Pasta night at home") + ) + + val result = DateSuggestionEngine.suggest(input) + + assertEquals("Pasta night at home (1–2 hours · moderate)", result.first().suggestedActivity) + } + + // ----------------------------------------------------------------- + // Trigger: completed weekly recap + // ----------------------------------------------------------------- + + @Test + fun `weekly recap completion triggers suggestion using favorite category`() { + val input = DateSuggestionInput( + weeklyRecapCompleted = true, + weeklyRecapFavoriteCategory = "romance" + ) + + val result = DateSuggestionEngine.suggest(input) + + assertEquals(1, result.size) + val suggestion = result.first() + assertEquals(DateSuggestionTrigger.WEEKLY_RECAP_COMPLETED, suggestion.triggerReason) + assertEquals("romance", suggestion.categoryId) + assertEquals(35, suggestion.priority) + assertTrue(SourceFlag.RECAP_CONTEXT in suggestion.sourceFlags) + assertTrue(SourceFlag.CATEGORY_METADATA in suggestion.sourceFlags) + assertTrue(suggestion.suggestedActivity.contains("A quiet, intentional evening together")) + } + + @Test + fun `weekly recap completion without favorite category uses fallback`() { + val input = DateSuggestionInput( + weeklyRecapCompleted = true, + weeklyRecapFavoriteCategory = null + ) + + val result = DateSuggestionEngine.suggest(input) + + assertEquals(1, result.size) + assertEquals("Plan something together this week (30 min · free)", result.first().suggestedActivity) + } + + @Test + fun `weekly recap not completed produces no suggestion`() { + val input = DateSuggestionInput( + weeklyRecapCompleted = false, + weeklyRecapFavoriteCategory = "romance" + ) + + val result = DateSuggestionEngine.suggest(input) + + assertTrue(result.isEmpty()) + } + + // ----------------------------------------------------------------- + // Trigger: completed challenge + // ----------------------------------------------------------------- + + @Test + fun `completed challenge triggers celebration suggestion`() { + val input = DateSuggestionInput( + challengeCompleted = true, + completedChallengeCategory = "connection" + ) + + val result = DateSuggestionEngine.suggest(input) + + assertEquals(1, result.size) + val suggestion = result.first() + assertEquals(DateSuggestionTrigger.CHALLENGE_COMPLETED, suggestion.triggerReason) + assertEquals("connection", suggestion.categoryId) + assertEquals(40, suggestion.priority) + assertTrue(SourceFlag.CHALLENGE_CONTEXT in suggestion.sourceFlags) + assertTrue(suggestion.suggestedActivity.contains("A low-pressure way to check in over something fun")) + } + + @Test + fun `incomplete challenge produces no suggestion`() { + val input = DateSuggestionInput( + challengeCompleted = false, + completedChallengeCategory = "connection" + ) + + val result = DateSuggestionEngine.suggest(input) + + assertTrue(result.isEmpty()) + } + + // ----------------------------------------------------------------- + // Trigger: partner saved a date idea + // ----------------------------------------------------------------- + + @Test + fun `partner saved date idea triggers suggestion`() { + val input = DateSuggestionInput( + partnerSavedDateIdeaIds = listOf("sunset-picnic"), + dateIdeaTitleCatalog = mapOf("sunset-picnic" to "Sunset picnic in the park"), + dateIdeaCategoryById = mapOf("sunset-picnic" to "romance") + ) + + val result = DateSuggestionEngine.suggest(input) + + assertEquals(1, result.size) + val suggestion = result.first() + assertEquals(DateSuggestionTrigger.PARTNER_SAVED_DATE_IDEA, suggestion.triggerReason) + assertEquals("romance", suggestion.categoryId) + assertEquals(45, suggestion.priority) + assertTrue(SourceFlag.PARTNER_ACTION in suggestion.sourceFlags) + assertEquals("Sunset picnic in the park (1–2 hours · low cost)", suggestion.suggestedActivity) + } + + @Test + fun `partner saved date idea without catalog uses generic copy`() { + val input = DateSuggestionInput( + partnerSavedDateIdeaIds = listOf("unknown-idea") + ) + + val result = DateSuggestionEngine.suggest(input) + + assertEquals(1, result.size) + assertEquals( + "A date idea your partner saved (30 min · free)", + result.first().suggestedActivity + ) + } + + @Test + fun `multiple saved ideas are prioritized with descending priority`() { + val input = DateSuggestionInput( + partnerSavedDateIdeaIds = listOf("first", "second", "third"), + dateIdeaTitleCatalog = mapOf( + "first" to "First idea", + "second" to "Second idea", + "third" to "Third idea" + ) + ) + + val result = DateSuggestionEngine.suggest(input) + + assertEquals(3, result.size) + assertEquals(45, result[0].priority) + assertEquals(42, result[1].priority) + assertEquals(39, result[2].priority) + } + + // ----------------------------------------------------------------- + // Trigger: shared interest revealed + // ----------------------------------------------------------------- + + @Test + fun `shared interest reveal triggers high priority suggestion`() { + val input = DateSuggestionInput( + hasSharedInterestReveal = true, + sharedInterestCategory = "travel" + ) + + val result = DateSuggestionEngine.suggest(input) + + assertEquals(1, result.size) + val suggestion = result.first() + assertEquals(DateSuggestionTrigger.SHARED_INTEREST_REVEALED, suggestion.triggerReason) + assertEquals("travel", suggestion.categoryId) + assertEquals(50, suggestion.priority) + assertTrue(suggestion.isFromRevealedAnswer) + assertTrue(SourceFlag.REVEALED_ANSWER in suggestion.sourceFlags) + assertTrue(SourceFlag.CATEGORY_METADATA in suggestion.sourceFlags) + assertTrue(suggestion.suggestedActivity.contains("Plan a mini local adventure")) + } + + @Test + fun `shared interest reveal without category uses fallback`() { + val input = DateSuggestionInput( + hasSharedInterestReveal = true, + sharedInterestCategory = null + ) + + val result = DateSuggestionEngine.suggest(input) + + assertEquals(1, result.size) + assertEquals( + "A date idea inspired by something you both revealed (30 min · free)", + result.first().suggestedActivity + ) + assertTrue(result.first().isFromRevealedAnswer) + } + + @Test + fun `shared interest not revealed produces no suggestion`() { + val input = DateSuggestionInput( + hasSharedInterestReveal = false, + sharedInterestCategory = "travel" + ) + + val result = DateSuggestionEngine.suggest(input) + + assertTrue(result.isEmpty()) + } + + // ----------------------------------------------------------------- + // Combined / priority ordering + // ----------------------------------------------------------------- + + @Test + fun `all triggers together are sorted by descending priority`() { + val input = DateSuggestionInput( + answeredQuestionsByCategory = listOf( + AnsweredQuestionRef("food"), + AnsweredQuestionRef("food"), + AnsweredQuestionRef("food") + ), + weeklyRecapCompleted = true, + weeklyRecapFavoriteCategory = "romance", + challengeCompleted = true, + completedChallengeCategory = "connection", + partnerSavedDateIdeaIds = listOf("saved-1"), + dateIdeaTitleCatalog = mapOf("saved-1" to "Saved idea"), + hasSharedInterestReveal = true, + sharedInterestCategory = "travel" + ) + + val result = DateSuggestionEngine.suggest(input) + + assertEquals(5, result.size) + assertEquals( + listOf( + DateSuggestionTrigger.SHARED_INTEREST_REVEALED, + DateSuggestionTrigger.PARTNER_SAVED_DATE_IDEA, + DateSuggestionTrigger.CHALLENGE_COMPLETED, + DateSuggestionTrigger.WEEKLY_RECAP_COMPLETED, + DateSuggestionTrigger.CATEGORY_SURGE + ), + result.map { it.triggerReason } + ) + } + + @Test + fun `empty input produces no suggestions`() { + val input = DateSuggestionInput() + + val result = DateSuggestionEngine.suggest(input) + + assertTrue(result.isEmpty()) + } + + // ----------------------------------------------------------------- + // Privacy / safety + // ----------------------------------------------------------------- + + @Test + fun `suggestions never include hidden answer text`() { + val input = DateSuggestionInput( + answeredQuestionsByCategory = listOf( + AnsweredQuestionRef("food"), + AnsweredQuestionRef("food"), + AnsweredQuestionRef("food") + ) + ) + + val result = DateSuggestionEngine.suggest(input) + + assertEquals(1, result.size) + assertFalse(result.first().suggestedActivity.contains("hidden")) + assertFalse(result.first().suggestedActivity.contains("private")) + } + + @Test + fun `shared interest suggestion is marked as revealed source only`() { + val input = DateSuggestionInput( + hasSharedInterestReveal = true, + sharedInterestCategory = "romance" + ) + + val result = DateSuggestionEngine.suggest(input) + + assertEquals(1, result.size) + assertTrue(result.first().isFromRevealedAnswer) + assertTrue(SourceFlag.REVEALED_ANSWER in result.first().sourceFlags) + } + + @Test + fun `category surge suggestions are not marked as revealed`() { + val input = DateSuggestionInput( + answeredQuestionsByCategory = listOf( + AnsweredQuestionRef("food", isRevealed = true), + AnsweredQuestionRef("food", isRevealed = true), + AnsweredQuestionRef("food", isRevealed = true) + ) + ) + + val result = DateSuggestionEngine.suggest(input) + + assertEquals(1, result.size) + assertFalse(result.first().isFromRevealedAnswer) + assertFalse(SourceFlag.REVEALED_ANSWER in result.first().sourceFlags) + } + + // ----------------------------------------------------------------- + // Helper correctness + // ----------------------------------------------------------------- + + @Test + fun `cost level mapping covers known categories`() { + assertEquals(DateCostLevel.LOW, DateSuggestionEngine.costLevelForCategory("adventure")) + assertEquals(DateCostLevel.MEDIUM, DateSuggestionEngine.costLevelForCategory("food")) + assertEquals(DateCostLevel.FREE, DateSuggestionEngine.costLevelForCategory("play")) + assertEquals(DateCostLevel.FREE, DateSuggestionEngine.costLevelForCategory(null)) + assertEquals(DateCostLevel.FREE, DateSuggestionEngine.costLevelForCategory("unknown")) + } + + @Test + fun `duration mapping covers known categories`() { + assertEquals("2–3 hours", DateSuggestionEngine.durationForCategory("travel")) + assertEquals("30 min", DateSuggestionEngine.durationForCategory("play")) + assertEquals("30 min", DateSuggestionEngine.durationForCategory(null)) + } + + @Test + fun `cost labels are human readable`() { + assertEquals("free", DateSuggestionEngine.costLabel(DateCostLevel.FREE)) + assertEquals("low cost", DateSuggestionEngine.costLabel(DateCostLevel.LOW)) + assertEquals("moderate", DateSuggestionEngine.costLabel(DateCostLevel.MEDIUM)) + assertEquals("higher cost", DateSuggestionEngine.costLabel(DateCostLevel.HIGH)) + } +}