diff --git a/app/src/main/java/app/closer/domain/MemoryCapsuleGenerator.kt b/app/src/main/java/app/closer/domain/MemoryCapsuleGenerator.kt new file mode 100644 index 00000000..7d1193c1 --- /dev/null +++ b/app/src/main/java/app/closer/domain/MemoryCapsuleGenerator.kt @@ -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 = emptyList(), + now: LocalDate = LocalDate.now() + ): List { + val tags = mutableListOf() + + 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 = 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" + } +} diff --git a/app/src/test/java/app/closer/domain/MemoryCapsuleGeneratorTest.kt b/app/src/test/java/app/closer/domain/MemoryCapsuleGeneratorTest.kt new file mode 100644 index 00000000..f9bf4a6c --- /dev/null +++ b/app/src/test/java/app/closer/domain/MemoryCapsuleGeneratorTest.kt @@ -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) + } +}