feat: add date suggestion engine for date planning loop (batch v1.0.8)
This commit is contained in:
parent
9db3c35f8d
commit
3575af1b6f
|
|
@ -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