feat: add memory capsule generator for saving meaningful moments (batch v1.0.9)
This commit is contained in:
parent
aff583f8bf
commit
f5340be156
|
|
@ -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