feat: add date suggestion engine for date planning loop (batch v1.0.8)
This commit is contained in:
parent
bed18ac8ee
commit
ba61d75c23
|
|
@ -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" -> "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<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
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue