feat: add memory capsule generator for saving meaningful moments (batch v1.0.9)
This commit is contained in:
parent
a500b86621
commit
5698e5436a
|
|
@ -0,0 +1,345 @@
|
|||
package app.closer.domain
|
||||
|
||||
import app.closer.domain.model.MemoryCapsule
|
||||
import app.closer.domain.model.MemoryCapsuleSource
|
||||
import app.closer.domain.model.MemoryChallengeEvent
|
||||
import app.closer.domain.model.MemoryDateIdeaEvent
|
||||
import app.closer.domain.model.MemoryManualInput
|
||||
import app.closer.domain.model.MemoryRecapEvent
|
||||
import app.closer.domain.model.MemoryRevealEvent
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
* Pure, deterministic logic for creating [MemoryCapsule] instances from couple events.
|
||||
*
|
||||
* No Android dependencies. The generator receives neutral, privacy-safe event metadata
|
||||
* and produces a capsule with a title, date, category, optional encrypted note, optional
|
||||
* photo path, and auto-generated tags.
|
||||
*
|
||||
* Privacy rules enforced here:
|
||||
* - Decrypted answer text is never read or included in generated output.
|
||||
* - Reveal-derived titles use only category metadata and the fact that a reveal happened.
|
||||
* - Manual notes are accepted as already-encrypted ciphertext; this object does not encrypt.
|
||||
* - Tags are built from category, source type, and date context only.
|
||||
*/
|
||||
object MemoryCapsuleGenerator {
|
||||
|
||||
/**
|
||||
* Default category for a memory when no specific category is supplied.
|
||||
*/
|
||||
internal const val DEFAULT_CATEGORY = "memory"
|
||||
|
||||
/**
|
||||
* Category key for a saved date idea that becomes a memory.
|
||||
*/
|
||||
internal const val DATE_CATEGORY = "date"
|
||||
|
||||
/**
|
||||
* Category key for a reveal that becomes a memory.
|
||||
*/
|
||||
internal const val REVEAL_CATEGORY = "reveal"
|
||||
|
||||
/**
|
||||
* Category key for a weekly recap that becomes a memory.
|
||||
*/
|
||||
internal const val RECAP_CATEGORY = "recap"
|
||||
|
||||
/**
|
||||
* Category key for a completed challenge that becomes a memory.
|
||||
*/
|
||||
internal const val CHALLENGE_CATEGORY = "challenge"
|
||||
|
||||
/**
|
||||
* Creates a memory capsule from a saved date idea after the date has happened.
|
||||
*
|
||||
* @param coupleId couple that owns the memory
|
||||
* @param authorId user creating the memory
|
||||
* @param event the saved date idea event
|
||||
* @param noteCiphertext optional encrypted post-date note
|
||||
* @param photoStoragePath optional photo path
|
||||
* @param now current date used when [event.scheduledDate] is absent
|
||||
*/
|
||||
fun fromDateIdea(
|
||||
coupleId: String,
|
||||
authorId: String,
|
||||
event: MemoryDateIdeaEvent,
|
||||
noteCiphertext: String? = null,
|
||||
photoStoragePath: String? = null,
|
||||
now: LocalDate = LocalDate.now()
|
||||
): MemoryCapsule {
|
||||
val date = event.scheduledDate ?: now
|
||||
val displayCategory = event.category.takeIf { it.isNotBlank() } ?: DATE_CATEGORY
|
||||
val title = when {
|
||||
event.title.isNotBlank() -> "Our ${displayTitleForCategory(displayCategory)} date: ${event.title}"
|
||||
else -> "Our ${displayTitleForCategory(displayCategory)} date"
|
||||
}
|
||||
|
||||
return MemoryCapsule(
|
||||
id = "",
|
||||
coupleId = coupleId,
|
||||
authorId = authorId,
|
||||
title = title,
|
||||
date = date,
|
||||
category = displayCategory,
|
||||
noteCiphertext = noteCiphertext,
|
||||
photoStoragePath = photoStoragePath,
|
||||
tags = buildTags(
|
||||
category = displayCategory,
|
||||
source = MemoryCapsuleSource.DATE_IDEA,
|
||||
date = date
|
||||
),
|
||||
source = MemoryCapsuleSource.DATE_IDEA,
|
||||
sourceRefId = event.dateIdeaId,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a memory capsule from a reveal when both partners agree to save it.
|
||||
*
|
||||
* The generated title never contains decrypted answer text.
|
||||
*/
|
||||
fun fromReveal(
|
||||
coupleId: String,
|
||||
authorId: String,
|
||||
event: MemoryRevealEvent,
|
||||
noteCiphertext: String? = null,
|
||||
photoStoragePath: String? = null
|
||||
): MemoryCapsule {
|
||||
val displayCategory = event.categoryId?.takeIf { it.isNotBlank() } ?: REVEAL_CATEGORY
|
||||
val title = "A moment we revealed together${categoryFragment(displayCategory)}"
|
||||
|
||||
return MemoryCapsule(
|
||||
id = "",
|
||||
coupleId = coupleId,
|
||||
authorId = authorId,
|
||||
title = title,
|
||||
date = event.revealedDate,
|
||||
category = displayCategory,
|
||||
noteCiphertext = noteCiphertext,
|
||||
photoStoragePath = photoStoragePath,
|
||||
tags = buildTags(
|
||||
category = displayCategory,
|
||||
source = MemoryCapsuleSource.REVEAL,
|
||||
date = event.revealedDate
|
||||
),
|
||||
source = MemoryCapsuleSource.REVEAL,
|
||||
sourceRefId = event.questionId,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a memory capsule from a completed weekly recap.
|
||||
*/
|
||||
fun fromWeeklyRecap(
|
||||
coupleId: String,
|
||||
authorId: String,
|
||||
event: MemoryRecapEvent,
|
||||
noteCiphertext: String? = null,
|
||||
photoStoragePath: String? = null
|
||||
): MemoryCapsule {
|
||||
val displayCategory = event.favoriteCategory?.takeIf { it.isNotBlank() } ?: RECAP_CATEGORY
|
||||
val weekNumber = weekNumberLabel(event.weekStart, event.weekEnd)
|
||||
val title = "Week $weekNumber together${categoryFragment(displayCategory)}"
|
||||
|
||||
return MemoryCapsule(
|
||||
id = "",
|
||||
coupleId = coupleId,
|
||||
authorId = authorId,
|
||||
title = title,
|
||||
date = event.weekEnd,
|
||||
category = displayCategory,
|
||||
noteCiphertext = noteCiphertext,
|
||||
photoStoragePath = photoStoragePath,
|
||||
tags = buildTags(
|
||||
category = displayCategory,
|
||||
source = MemoryCapsuleSource.WEEKLY_RECAP,
|
||||
date = event.weekEnd,
|
||||
extras = weekNumber?.let { listOf("week-$it") } ?: emptyList()
|
||||
),
|
||||
source = MemoryCapsuleSource.WEEKLY_RECAP,
|
||||
sourceRefId = event.recapId,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a memory capsule from a completed challenge.
|
||||
*/
|
||||
fun fromChallenge(
|
||||
coupleId: String,
|
||||
authorId: String,
|
||||
event: MemoryChallengeEvent,
|
||||
noteCiphertext: String? = null,
|
||||
photoStoragePath: String? = null
|
||||
): MemoryCapsule {
|
||||
val displayCategory = event.challengeCategory?.takeIf { it.isNotBlank() } ?: CHALLENGE_CATEGORY
|
||||
val title = "Challenge completed${categoryFragment(displayCategory)}"
|
||||
|
||||
return MemoryCapsule(
|
||||
id = "",
|
||||
coupleId = coupleId,
|
||||
authorId = authorId,
|
||||
title = title,
|
||||
date = event.completedDate,
|
||||
category = displayCategory,
|
||||
noteCiphertext = noteCiphertext,
|
||||
photoStoragePath = photoStoragePath,
|
||||
tags = buildTags(
|
||||
category = displayCategory,
|
||||
source = MemoryCapsuleSource.CHALLENGE,
|
||||
date = event.completedDate
|
||||
),
|
||||
source = MemoryCapsuleSource.CHALLENGE,
|
||||
sourceRefId = event.challengeId,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a memory capsule from manual user input.
|
||||
*
|
||||
* The caller is responsible for encrypting [input.noteCiphertext] before invoking
|
||||
* this generator. This object never sees plaintext notes.
|
||||
*/
|
||||
fun fromManualInput(
|
||||
coupleId: String,
|
||||
authorId: String,
|
||||
input: MemoryManualInput,
|
||||
now: LocalDate = LocalDate.now()
|
||||
): MemoryCapsule {
|
||||
val displayCategory = input.category.takeIf { it.isNotBlank() } ?: DEFAULT_CATEGORY
|
||||
val title = input.title.takeIf { it.isNotBlank() }
|
||||
?: "A moment from ${input.date.format(DateTimeFormatter.ofPattern("MMMM d"))}"
|
||||
|
||||
return MemoryCapsule(
|
||||
id = "",
|
||||
coupleId = coupleId,
|
||||
authorId = authorId,
|
||||
title = title,
|
||||
date = input.date,
|
||||
category = displayCategory,
|
||||
noteCiphertext = input.noteCiphertext,
|
||||
photoStoragePath = input.photoStoragePath,
|
||||
tags = buildTags(
|
||||
category = displayCategory,
|
||||
source = MemoryCapsuleSource.MANUAL,
|
||||
date = input.date,
|
||||
now = now
|
||||
),
|
||||
source = MemoryCapsuleSource.MANUAL,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds tags from safe context metadata.
|
||||
*
|
||||
* Tags are lowercase, hyphenated, and never contain user text.
|
||||
*/
|
||||
internal fun buildTags(
|
||||
category: String,
|
||||
source: MemoryCapsuleSource,
|
||||
date: LocalDate,
|
||||
extras: List<String> = emptyList(),
|
||||
now: LocalDate = LocalDate.now()
|
||||
): List<String> {
|
||||
val tags = mutableListOf<String>()
|
||||
|
||||
val normalizedCategory = category.lowercase().replace(" ", "-")
|
||||
if (normalizedCategory.isNotBlank()) {
|
||||
tags.add(normalizedCategory)
|
||||
}
|
||||
|
||||
tags.add(source.toFirestoreValue().replace("_", "-"))
|
||||
|
||||
val monthTag = date.month.name.lowercase()
|
||||
tags.add(monthTag)
|
||||
|
||||
val seasonTag = seasonForMonth(date.monthValue)
|
||||
tags.add(seasonTag)
|
||||
|
||||
if (date.year == now.year) {
|
||||
tags.add("this-year")
|
||||
}
|
||||
|
||||
val dayTag = date.dayOfWeek.name.lowercase()
|
||||
tags.add(dayTag)
|
||||
|
||||
extras.forEach { extra ->
|
||||
val normalized = extra.lowercase().replace(" ", "-")
|
||||
if (normalized.isNotBlank() && normalized !in tags) {
|
||||
tags.add(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
return tags.distinct()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable category fragment for titles, e.g. " in Intimacy".
|
||||
*
|
||||
* Falls back to empty string for generic categories so titles stay warm and short.
|
||||
*/
|
||||
internal fun categoryFragment(category: String): String {
|
||||
if (category.isBlank()) return ""
|
||||
val normalized = category.lowercase()
|
||||
if (normalized in setOf("date", "reveal", "recap", "challenge", "memory")) return ""
|
||||
val display = displayTitleForCategory(normalized)
|
||||
return " in $display"
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in display names for known category keys.
|
||||
*/
|
||||
private val CATEGORY_DISPLAY_NAMES: Map<String, String> = mapOf(
|
||||
"adventure" to "Adventure",
|
||||
"travel" to "Travel",
|
||||
"food" to "Food",
|
||||
"learning" to "Learning",
|
||||
"romance" to "Romance",
|
||||
"intimacy" to "Intimacy",
|
||||
"connection" to "Connection",
|
||||
"seasonal" to "Seasonal",
|
||||
"play" to "Play",
|
||||
"date" to "Date",
|
||||
"reveal" to "Reveal",
|
||||
"recap" to "Recap",
|
||||
"challenge" to "Challenge",
|
||||
"memory" to "Memory"
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns a friendly display word for a category in a title.
|
||||
*/
|
||||
internal fun displayTitleForCategory(category: String): String {
|
||||
val normalized = category.lowercase()
|
||||
return CATEGORY_DISPLAY_NAMES[normalized]
|
||||
?: normalized.replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a week number label from a recap's week start/end.
|
||||
*
|
||||
* Weeks are numbered from the start of the year. Returns null when inputs are missing.
|
||||
*/
|
||||
internal fun weekNumberLabel(weekStart: LocalDate?, weekEnd: LocalDate?): String? {
|
||||
if (weekStart == null || weekEnd == null) return null
|
||||
val isoWeek = weekEnd.get(java.time.temporal.WeekFields.ISO.weekOfWeekBasedYear())
|
||||
return isoWeek.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a season tag for a month number (1-12).
|
||||
*/
|
||||
internal fun seasonForMonth(month: Int): String = when (month) {
|
||||
12, 1, 2 -> "winter"
|
||||
3, 4, 5 -> "spring"
|
||||
6, 7, 8 -> "summer"
|
||||
9, 10, 11 -> "fall"
|
||||
else -> "unknown"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
package app.closer.domain
|
||||
|
||||
import app.closer.domain.model.MemoryCapsuleSource
|
||||
import app.closer.domain.model.MemoryChallengeEvent
|
||||
import app.closer.domain.model.MemoryDateIdeaEvent
|
||||
import app.closer.domain.model.MemoryManualInput
|
||||
import app.closer.domain.model.MemoryRecapEvent
|
||||
import app.closer.domain.model.MemoryRevealEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.LocalDate
|
||||
|
||||
class MemoryCapsuleGeneratorTest {
|
||||
|
||||
private val coupleId = "couple_1"
|
||||
private val authorId = "user_1"
|
||||
private val today: LocalDate = LocalDate.of(2026, 6, 19)
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Date idea → memory
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `date idea produces memory with category title`() {
|
||||
val event = MemoryDateIdeaEvent(
|
||||
dateIdeaId = "idea_1",
|
||||
title = "Pasta night at home",
|
||||
category = "food",
|
||||
scheduledDate = today
|
||||
)
|
||||
|
||||
val capsule = MemoryCapsuleGenerator.fromDateIdea(coupleId, authorId, event, now = today)
|
||||
|
||||
assertEquals(coupleId, capsule.coupleId)
|
||||
assertEquals(authorId, capsule.authorId)
|
||||
assertEquals("Our Food date: Pasta night at home", capsule.title)
|
||||
assertEquals(today, capsule.date)
|
||||
assertEquals("food", capsule.category)
|
||||
assertEquals(MemoryCapsuleSource.DATE_IDEA, capsule.source)
|
||||
assertEquals("idea_1", capsule.sourceRefId)
|
||||
assertTrue(capsule.tags.contains("food"))
|
||||
assertTrue(capsule.tags.contains("date-idea"))
|
||||
assertNull(capsule.noteCiphertext)
|
||||
assertNull(capsule.photoStoragePath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `date idea without scheduled date uses now`() {
|
||||
val event = MemoryDateIdeaEvent(
|
||||
dateIdeaId = "idea_2",
|
||||
title = "Sunset hike",
|
||||
category = "adventure"
|
||||
)
|
||||
|
||||
val capsule = MemoryCapsuleGenerator.fromDateIdea(coupleId, authorId, event, now = today)
|
||||
|
||||
assertEquals(today, capsule.date)
|
||||
assertTrue(capsule.tags.contains("adventure"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `date idea without category defaults to date category`() {
|
||||
val event = MemoryDateIdeaEvent(dateIdeaId = "idea_3", title = "Movie night")
|
||||
|
||||
val capsule = MemoryCapsuleGenerator.fromDateIdea(coupleId, authorId, event, now = today)
|
||||
|
||||
assertEquals("date", capsule.category)
|
||||
assertEquals("Our Date date: Movie night", capsule.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `date idea accepts encrypted note and photo path`() {
|
||||
val event = MemoryDateIdeaEvent(
|
||||
dateIdeaId = "idea_4",
|
||||
title = "Farmers market brunch",
|
||||
category = "food",
|
||||
scheduledDate = today
|
||||
)
|
||||
|
||||
val capsule = MemoryCapsuleGenerator.fromDateIdea(
|
||||
coupleId,
|
||||
authorId,
|
||||
event,
|
||||
noteCiphertext = "enc:v1:abc123",
|
||||
photoStoragePath = "couples/couple_1/memories/photo_1.jpg",
|
||||
now = today
|
||||
)
|
||||
|
||||
assertEquals("enc:v1:abc123", capsule.noteCiphertext)
|
||||
assertEquals("couples/couple_1/memories/photo_1.jpg", capsule.photoStoragePath)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Reveal → memory
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `reveal produces memory without answer text in title`() {
|
||||
val event = MemoryRevealEvent(
|
||||
questionId = "q_1",
|
||||
categoryId = "intimacy",
|
||||
revealedDate = today
|
||||
)
|
||||
|
||||
val capsule = MemoryCapsuleGenerator.fromReveal(coupleId, authorId, event)
|
||||
|
||||
assertEquals("A moment we revealed together in Intimacy", capsule.title)
|
||||
assertEquals("intimacy", capsule.category)
|
||||
assertEquals(today, capsule.date)
|
||||
assertEquals(MemoryCapsuleSource.REVEAL, capsule.source)
|
||||
assertEquals("q_1", capsule.sourceRefId)
|
||||
assertFalse(capsule.title.contains("secret"))
|
||||
assertFalse(capsule.title.contains("password"))
|
||||
assertFalse(capsule.tags.any { it.contains("secret") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reveal without category uses reveal category`() {
|
||||
val event = MemoryRevealEvent(
|
||||
questionId = "q_2",
|
||||
revealedDate = today
|
||||
)
|
||||
|
||||
val capsule = MemoryCapsuleGenerator.fromReveal(coupleId, authorId, event)
|
||||
|
||||
assertEquals("reveal", capsule.category)
|
||||
assertEquals("A moment we revealed together", capsule.title)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Weekly recap → memory
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `weekly recap produces memory with week number`() {
|
||||
val weekStart = LocalDate.of(2026, 6, 14)
|
||||
val weekEnd = weekStart.plusDays(6)
|
||||
val event = MemoryRecapEvent(
|
||||
recapId = "recap_1",
|
||||
weekStart = weekStart,
|
||||
weekEnd = weekEnd,
|
||||
favoriteCategory = "play"
|
||||
)
|
||||
|
||||
val capsule = MemoryCapsuleGenerator.fromWeeklyRecap(coupleId, authorId, event)
|
||||
|
||||
assertEquals("Week 25 together in Play", capsule.title)
|
||||
assertEquals("play", capsule.category)
|
||||
assertEquals(weekEnd, capsule.date)
|
||||
assertEquals(MemoryCapsuleSource.WEEKLY_RECAP, capsule.source)
|
||||
assertEquals("recap_1", capsule.sourceRefId)
|
||||
assertTrue(capsule.tags.contains("play"))
|
||||
assertTrue(capsule.tags.contains("weekly-recap"))
|
||||
assertTrue(capsule.tags.contains("week-25"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekly recap without favorite category uses recap category`() {
|
||||
val weekStart = LocalDate.of(2026, 6, 14)
|
||||
val weekEnd = weekStart.plusDays(6)
|
||||
val event = MemoryRecapEvent(
|
||||
recapId = "recap_2",
|
||||
weekStart = weekStart,
|
||||
weekEnd = weekEnd
|
||||
)
|
||||
|
||||
val capsule = MemoryCapsuleGenerator.fromWeeklyRecap(coupleId, authorId, event)
|
||||
|
||||
assertEquals("recap", capsule.category)
|
||||
assertEquals("Week 25 together", capsule.title)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Challenge → memory
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `challenge completion produces memory`() {
|
||||
val event = MemoryChallengeEvent(
|
||||
challengeId = "challenge_1",
|
||||
challengeCategory = "connection",
|
||||
completedDate = today
|
||||
)
|
||||
|
||||
val capsule = MemoryCapsuleGenerator.fromChallenge(coupleId, authorId, event)
|
||||
|
||||
assertEquals("Challenge completed in Connection", capsule.title)
|
||||
assertEquals("connection", capsule.category)
|
||||
assertEquals(today, capsule.date)
|
||||
assertEquals(MemoryCapsuleSource.CHALLENGE, capsule.source)
|
||||
assertEquals("challenge_1", capsule.sourceRefId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `challenge without category uses challenge category`() {
|
||||
val event = MemoryChallengeEvent(
|
||||
challengeId = "challenge_2",
|
||||
completedDate = today
|
||||
)
|
||||
|
||||
val capsule = MemoryCapsuleGenerator.fromChallenge(coupleId, authorId, event)
|
||||
|
||||
assertEquals("challenge", capsule.category)
|
||||
assertEquals("Challenge completed", capsule.title)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Manual memory
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `manual input produces memory with encrypted note`() {
|
||||
val input = MemoryManualInput(
|
||||
title = "Our first road trip",
|
||||
date = today,
|
||||
category = "travel",
|
||||
noteCiphertext = "enc:v1:note_secret",
|
||||
photoStoragePath = "photos/roadtrip.jpg"
|
||||
)
|
||||
|
||||
val capsule = MemoryCapsuleGenerator.fromManualInput(coupleId, authorId, input, now = today)
|
||||
|
||||
assertEquals("Our first road trip", capsule.title)
|
||||
assertEquals("travel", capsule.category)
|
||||
assertEquals(today, capsule.date)
|
||||
assertEquals("enc:v1:note_secret", capsule.noteCiphertext)
|
||||
assertEquals("photos/roadtrip.jpg", capsule.photoStoragePath)
|
||||
assertEquals(MemoryCapsuleSource.MANUAL, capsule.source)
|
||||
assertTrue(capsule.tags.contains("travel"))
|
||||
assertTrue(capsule.tags.contains("manual"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual input with blank title uses fallback date title`() {
|
||||
val input = MemoryManualInput(
|
||||
title = "",
|
||||
date = LocalDate.of(2026, 12, 25),
|
||||
category = "romance"
|
||||
)
|
||||
|
||||
val capsule = MemoryCapsuleGenerator.fromManualInput(coupleId, authorId, input, now = today)
|
||||
|
||||
assertEquals("A moment from December 25", capsule.title)
|
||||
assertEquals("romance", capsule.category)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual input with blank category defaults to memory category`() {
|
||||
val input = MemoryManualInput(
|
||||
title = "Random note",
|
||||
date = today,
|
||||
category = ""
|
||||
)
|
||||
|
||||
val capsule = MemoryCapsuleGenerator.fromManualInput(coupleId, authorId, input, now = today)
|
||||
|
||||
assertEquals("memory", capsule.category)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Tags
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `tags include category source month season and weekday`() {
|
||||
val tags = MemoryCapsuleGenerator.buildTags(
|
||||
category = "food",
|
||||
source = MemoryCapsuleSource.DATE_IDEA,
|
||||
date = today,
|
||||
now = today
|
||||
)
|
||||
|
||||
assertTrue(tags.contains("food"))
|
||||
assertTrue(tags.contains("date-idea"))
|
||||
assertTrue(tags.contains("june"))
|
||||
assertTrue(tags.contains("summer"))
|
||||
assertTrue(tags.contains("friday"))
|
||||
assertTrue(tags.contains("this-year"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tags are distinct and lowercase`() {
|
||||
val tags = MemoryCapsuleGenerator.buildTags(
|
||||
category = "food",
|
||||
source = MemoryCapsuleSource.MANUAL,
|
||||
date = today,
|
||||
extras = listOf("Food", "food", "manual"),
|
||||
now = today
|
||||
)
|
||||
|
||||
assertEquals(tags.size, tags.toSet().size)
|
||||
assertTrue(tags.all { it == it.lowercase() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tags handle multi word category`() {
|
||||
val tags = MemoryCapsuleGenerator.buildTags(
|
||||
category = "road trip",
|
||||
source = MemoryCapsuleSource.MANUAL,
|
||||
date = today,
|
||||
now = today
|
||||
)
|
||||
|
||||
assertTrue(tags.contains("road-trip"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extras are normalized and deduplicated`() {
|
||||
val tags = MemoryCapsuleGenerator.buildTags(
|
||||
category = "food",
|
||||
source = MemoryCapsuleSource.DATE_IDEA,
|
||||
date = today,
|
||||
extras = listOf("Week 25", "week-25", "Food"),
|
||||
now = today
|
||||
)
|
||||
|
||||
assertEquals(1, tags.count { it == "week-25" })
|
||||
assertTrue(tags.contains("week-25"))
|
||||
assertFalse(tags.contains("week 25"))
|
||||
assertFalse(tags.contains("Food"))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `categoryFragment omits generic categories`() {
|
||||
assertEquals("", MemoryCapsuleGenerator.categoryFragment("date"))
|
||||
assertEquals("", MemoryCapsuleGenerator.categoryFragment("reveal"))
|
||||
assertEquals("", MemoryCapsuleGenerator.categoryFragment("recap"))
|
||||
assertEquals("", MemoryCapsuleGenerator.categoryFragment("challenge"))
|
||||
assertEquals("", MemoryCapsuleGenerator.categoryFragment("memory"))
|
||||
assertEquals(" in Romance", MemoryCapsuleGenerator.categoryFragment("romance"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `displayTitleForCategory returns known category display name`() {
|
||||
assertEquals("Adventure", MemoryCapsuleGenerator.displayTitleForCategory("adventure"))
|
||||
assertEquals("Intimacy", MemoryCapsuleGenerator.displayTitleForCategory("intimacy"))
|
||||
assertEquals("Custom", MemoryCapsuleGenerator.displayTitleForCategory("custom"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekNumberLabel returns ISO week number`() {
|
||||
val weekStart = LocalDate.of(2026, 6, 14)
|
||||
val weekEnd = weekStart.plusDays(6)
|
||||
|
||||
assertEquals("25", MemoryCapsuleGenerator.weekNumberLabel(weekStart, weekEnd))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekNumberLabel returns null when inputs are null`() {
|
||||
assertNull(MemoryCapsuleGenerator.weekNumberLabel(null, today))
|
||||
assertNull(MemoryCapsuleGenerator.weekNumberLabel(today, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seasonForMonth returns correct season`() {
|
||||
assertEquals("winter", MemoryCapsuleGenerator.seasonForMonth(12))
|
||||
assertEquals("winter", MemoryCapsuleGenerator.seasonForMonth(1))
|
||||
assertEquals("spring", MemoryCapsuleGenerator.seasonForMonth(4))
|
||||
assertEquals("summer", MemoryCapsuleGenerator.seasonForMonth(6))
|
||||
assertEquals("fall", MemoryCapsuleGenerator.seasonForMonth(10))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Privacy
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `generated titles never contain decrypted answer text`() {
|
||||
val revealEvent = MemoryRevealEvent(
|
||||
questionId = "q_secret",
|
||||
categoryId = "intimacy",
|
||||
revealedDate = today
|
||||
)
|
||||
val revealCapsule = MemoryCapsuleGenerator.fromReveal(coupleId, authorId, revealEvent)
|
||||
|
||||
val recapEvent = MemoryRecapEvent(
|
||||
recapId = "recap_secret",
|
||||
weekStart = today.minusDays(6),
|
||||
weekEnd = today,
|
||||
favoriteCategory = "intimacy"
|
||||
)
|
||||
val recapCapsule = MemoryCapsuleGenerator.fromWeeklyRecap(coupleId, authorId, recapEvent)
|
||||
|
||||
val sensitiveValues = listOf("I love you", "my secret", "private answer", "plaintext")
|
||||
listOf(revealCapsule, recapCapsule).forEach { capsule ->
|
||||
sensitiveValues.forEach { value ->
|
||||
assertFalse(capsule.title.contains(value))
|
||||
assertFalse(capsule.tags.any { it.contains(value.lowercase().replace(" ", "-")) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual input generator does not mutate encrypted note`() {
|
||||
val input = MemoryManualInput(
|
||||
title = "Private thought",
|
||||
date = today,
|
||||
category = "intimacy",
|
||||
noteCiphertext = "enc:v1:do-not-read"
|
||||
)
|
||||
|
||||
val capsule = MemoryCapsuleGenerator.fromManualInput(coupleId, authorId, input, now = today)
|
||||
|
||||
assertEquals("enc:v1:do-not-read", capsule.noteCiphertext)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Determinism
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `same input produces identical capsules`() {
|
||||
val event = MemoryDateIdeaEvent(
|
||||
dateIdeaId = "idea_5",
|
||||
title = "Board game cafe",
|
||||
category = "play",
|
||||
scheduledDate = today
|
||||
)
|
||||
|
||||
val first = MemoryCapsuleGenerator.fromDateIdea(coupleId, authorId, event, now = today)
|
||||
val second = MemoryCapsuleGenerator.fromDateIdea(coupleId, authorId, event, now = today)
|
||||
|
||||
assertEquals(first, second)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue