feat: add date suggestion engine for date planning loop (batch v1.0.8)

This commit is contained in:
null 2026-06-19 22:44:33 -05:00
parent 9db3c35f8d
commit 3575af1b6f
3 changed files with 736 additions and 0 deletions

View File

@ -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<DateSuggestion> {
val suggestions = mutableListOf<DateSuggestion>()
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<DateSuggestion> {
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<DateSuggestion> {
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<DateSuggestion> {
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<DateSuggestion> {
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<DateSuggestion> {
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<String, String>,
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" -> "23 hours"
"food" -> "12 hours"
"learning" -> "12 hours"
"romance", "intimacy" -> "12 hours"
"connection", "play" -> "30 min"
"seasonal" -> "12 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<AnsweredQuestionRef> = emptyList(),
val weeklyRecapCompleted: Boolean = false,
val weeklyRecapFavoriteCategory: String? = null,
val challengeCompleted: Boolean = false,
val completedChallengeCategory: String? = null,
val partnerSavedDateIdeaIds: List<String> = emptyList(),
val dateIdeaTitleCatalog: Map<String, String> = emptyMap(),
val dateIdeaCategoryById: Map<String, String> = 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
)

View File

@ -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<SourceFlag> = 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
}

View File

@ -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 (12 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 (12 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("23 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))
}
}