feat(contacts): idempotent special-dates sync engine

The engine reconciles device contact dates into the per-type local calendars.
Each run is an idempotent diff keyed on the deterministic UID_2445:

- new contacts inserted (seeding reminders via resolveDefaultReminder, plus a
  useful per-calendar all-day default of on-the-day + a week before so birthdays
  get lead time out of the box);
- changed contacts get a targeted managed-column update (title/dtstart/rrule
  only) — reminders/location/notes are never re-touched, so user edits survive;
- removed contacts deleted.

Managed calendars are created/adopted/removed per enabled type (reconcileCalendars,
self-healing against a stored-id that no longer exists), all-day FREQ=YEARLY
events anchored at the birth year (or a leap anchor when year-less). Pure helpers
(uid, anchor, age snapshot, title templating) and the diff are extracted for
unit testing; a stateful fake exercises full-run idempotency and user-data
preservation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 10:58:26 +02:00
parent f7c0b8a957
commit 3aecb19a0c
8 changed files with 661 additions and 2 deletions

View File

@@ -86,6 +86,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun deleteCalendar(id: Long) {
writeError?.let { throw it }
deletedCalendarIds += id
managedCalendarRows.removeAll { it.id == id }
managedEvents.remove(id)
}
/** All-day reminder fire-time minute-of-day passed into the last write. */
@@ -147,9 +149,9 @@ internal class FakeCalendarDataSource : CalendarDataSource {
// --- Managed special-dates surface (stateful, so a re-sync sees inserts) ---
var findManagedCalendarsResult: List<ManagedCalendarRow> = emptyList()
var nextManagedEventId: Long = 1_000L
private var managedCalendarSeq: Long = 700L
private val managedCalendarRows = mutableListOf<ManagedCalendarRow>()
private val managedEvents = mutableMapOf<Long, MutableList<ManagedEventRow>>()
data class CreatedManagedCalendar(val displayName: String, val color: Int, val type: SpecialDateType)
@@ -157,15 +159,22 @@ internal class FakeCalendarDataSource : CalendarDataSource {
val insertedManagedEvents = mutableListOf<Pair<EventForm, String>>()
val updatedManagedFields = mutableListOf<Pair<Long, Map<String, Any?>>>()
/** Preset an orphan managed calendar (e.g. to test adoption after a prefs wipe). */
fun presetManagedCalendar(id: Long, type: SpecialDateType) {
managedCalendarRows += ManagedCalendarRow(id, type)
managedEvents.getOrPut(id) { mutableListOf() }
}
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
writeError?.let { throw it }
createdManagedCalendars += CreatedManagedCalendar(displayName, color, type)
val id = managedCalendarSeq++
managedCalendarRows += ManagedCalendarRow(id, type)
managedEvents.getOrPut(id) { mutableListOf() }
return id
}
override fun findManagedCalendars(): List<ManagedCalendarRow> = findManagedCalendarsResult
override fun findManagedCalendars(): List<ManagedCalendarRow> = managedCalendarRows.toList()
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> =
managedEvents[calendarId]?.toList() ?: emptyList()

View File

@@ -0,0 +1,196 @@
package de.jeanlucmakiola.calendula.data.contacts
import android.provider.CalendarContract
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class SpecialDatesSyncEngineTest {
private val today = LocalDate(2026, 3, 1)
private class FakeContacts(
var permission: Boolean = true,
var dates: List<ContactSpecialDate> = emptyList(),
) : ContactSpecialDatesDataSource {
override fun hasPermission() = permission
override fun readSpecialDates() = dates
}
private class FakeSpec : SpecialDatesCalendarSpec {
override fun displayName(type: SpecialDateType) = type.name
override fun color(type: SpecialDateType) = 0xFF112233.toInt()
override fun defaultTitleTemplate(type: SpecialDateType) = when (type) {
SpecialDateType.Birthday -> "{name}'s birthday ({age})"
SpecialDateType.Anniversary -> "{name}'s anniversary"
SpecialDateType.Custom -> "{name}"
}
}
private fun prefs(tempDir: Path): SettingsPrefs {
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
produceFile = { tempDir.resolve("settings_test.preferences_pb").toFile() },
)
return SettingsPrefs(store)
}
private fun birthday(key: String, name: String, month: Int, day: Int, year: Int?) =
ContactSpecialDate(key, name, SpecialDateType.Birthday, month, day, year)
private suspend fun engineWith(
tempDir: Path,
contacts: FakeContacts,
calendars: FakeCalendarDataSource = FakeCalendarDataSource(),
enabled: Boolean = true,
): Pair<SpecialDatesSyncEngine, SettingsPrefs> {
val settings = prefs(tempDir)
if (enabled) settings.setSpecialDatesEnabled(true)
return SpecialDatesSyncEngine(contacts, calendars, settings, FakeSpec()) to settings
}
@Test
fun `disabled feature is a no-op`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val (engine, _) = engineWith(tempDir, FakeContacts(), calendars, enabled = false)
assertThat(engine.sync(today)).isEqualTo(SpecialDatesSyncResult.Disabled)
assertThat(calendars.createdManagedCalendars).isEmpty()
}
@Test
fun `missing permission reports stalled without touching calendars`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val (engine, _) = engineWith(tempDir, FakeContacts(permission = false), calendars)
assertThat(engine.sync(today)).isEqualTo(SpecialDatesSyncResult.PermissionMissing)
assertThat(calendars.createdManagedCalendars).isEmpty()
}
@Test
fun `first sync creates calendars and inserts one event per contact`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(
dates = listOf(
birthday("a", "Jane", 5, 14, 1990),
birthday("b", "Bob", 12, 25, null),
),
)
val (engine, settings) = engineWith(tempDir, contacts, calendars)
assertThat(engine.sync(today)).isEqualTo(SpecialDatesSyncResult.Success)
// All three type calendars exist; the birthday one holds both contacts.
assertThat(calendars.createdManagedCalendars.map { it.type })
.containsExactlyElementsIn(SpecialDateType.entries)
assertThat(calendars.insertedManagedEvents).hasSize(2)
assertThat(calendars.insertedManagedEvents.map { it.second })
.containsExactly("contact-birthday:a@calendula", "contact-birthday:b@calendula")
// Year-less contact drops the age; year-known keeps it.
val titles = calendars.insertedManagedEvents.map { it.first.title }
assertThat(titles).contains("Jane's birthday (36)")
assertThat(titles).contains("Bob's birthday")
// Calendar ids were persisted for the editor / next sync.
assertThat(settings.specialDatesCalendars.first()).containsKey(SpecialDateType.Birthday)
}
@Test
fun `a second sync with no changes writes nothing`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, _) = engineWith(tempDir, contacts, calendars)
engine.sync(today)
val insertsAfterFirst = calendars.insertedManagedEvents.size
val calendarsAfterFirst = calendars.createdManagedCalendars.size
engine.sync(today)
assertThat(calendars.insertedManagedEvents).hasSize(insertsAfterFirst)
assertThat(calendars.createdManagedCalendars).hasSize(calendarsAfterFirst)
assertThat(calendars.updatedManagedFields).isEmpty()
assertThat(calendars.deletedEventIds).isEmpty()
}
@Test
fun `a renamed contact triggers a title-only managed update`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, _) = engineWith(tempDir, contacts, calendars)
engine.sync(today)
contacts.dates = listOf(birthday("a", "Janet", 5, 14, 1990))
engine.sync(today)
assertThat(calendars.updatedManagedFields).hasSize(1)
val (_, columns) = calendars.updatedManagedFields.single()
// Only managed columns are ever written — never reminders/location.
assertThat(columns.keys).containsExactly(CalendarContract.Events.TITLE)
assertThat(columns[CalendarContract.Events.TITLE]).isEqualTo("Janet's birthday (36)")
}
@Test
fun `a removed contact deletes its event`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, _) = engineWith(tempDir, contacts, calendars)
engine.sync(today)
contacts.dates = emptyList()
engine.sync(today)
assertThat(calendars.deletedEventIds).hasSize(1)
}
@Test
fun `disabling a type deletes its calendar on the next sync`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource()
val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
engine.sync(today)
val anniversaryId = settings.specialDatesCalendars.first().getValue(SpecialDateType.Anniversary)
settings.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
engine.sync(today)
assertThat(calendars.deletedCalendarIds).contains(anniversaryId)
assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary)
}
// --- pure diff ---
private fun desired(uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
ManagedEventDesired(uid, title, dt, rrule)
private fun row(id: Long, uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
ManagedEventRow(id, uid, title, dt, rrule)
@Test
fun `diff inserts new, deletes gone, and leaves unchanged alone`() {
val diff = diffManagedEvents(
desired = listOf(desired("keep", "Jane", 100), desired("new", "Ada", 200)),
existing = listOf(row(1, "keep", "Jane", 100), row(2, "gone", "Old", 300)),
)
assertThat(diff.insertUids).containsExactly("new")
assertThat(diff.deleteEventIds).containsExactly(2L)
assertThat(diff.updates).isEmpty()
}
@Test
fun `diff emits only the changed managed columns`() {
val diff = diffManagedEvents(
desired = listOf(desired("a", "Janet", 150)),
existing = listOf(row(9, "a", "Jane", 100)),
)
val update = diff.updates.single()
assertThat(update.eventId).isEqualTo(9L)
assertThat(update.columns.keys)
.containsExactly(CalendarContract.Events.TITLE, CalendarContract.Events.DTSTART)
}
}

View File

@@ -0,0 +1,69 @@
package de.jeanlucmakiola.calendula.domain.contacts
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
class SpecialDateFormattingTest {
private fun birthday(month: Int, day: Int, year: Int?) = ContactSpecialDate(
lookupKey = "k",
displayName = "Jane",
type = SpecialDateType.Birthday,
month = month,
day = day,
year = year,
)
@Test
fun `uid is deterministic and namespaced by type`() {
assertThat(managedEventUid(SpecialDateType.Birthday, "abc"))
.isEqualTo("contact-birthday:abc@calendula")
assertThat(managedEventUid(SpecialDateType.Anniversary, "abc"))
.isEqualTo("contact-anniversary:abc@calendula")
}
@Test
fun `anchor date uses the known year`() {
assertThat(birthday(5, 14, 1990).anchorDate()).isEqualTo(LocalDate(1990, 5, 14))
}
@Test
fun `anchor date falls back to a leap year when year-less`() {
assertThat(birthday(2, 29, null).anchorDate()).isEqualTo(LocalDate(YEARLESS_ANCHOR_YEAR, 2, 29))
}
@Test
fun `age is null when the year is unknown`() {
assertThat(birthday(5, 14, null).ageAtNextOccurrence(LocalDate(2026, 1, 1))).isNull()
}
@Test
fun `age counts the upcoming birthday when it is still ahead this year`() {
// Born 1990-05-14; today 2026-03-01 → next birthday 2026 → turns 36.
assertThat(birthday(5, 14, 1990).ageAtNextOccurrence(LocalDate(2026, 3, 1))).isEqualTo(36)
}
@Test
fun `age rolls to next year once the birthday has passed`() {
// Born 1990-05-14; today 2026-06-01 → next birthday 2027 → turns 37.
assertThat(birthday(5, 14, 1990).ageAtNextOccurrence(LocalDate(2026, 6, 1))).isEqualTo(37)
}
@Test
fun `age counts today's birthday as this year`() {
assertThat(birthday(5, 14, 1990).ageAtNextOccurrence(LocalDate(2026, 5, 14))).isEqualTo(36)
}
@Test
fun `title substitutes name and age`() {
assertThat(renderSpecialDateTitle("{name}'s birthday ({age})", "Jane", 30))
.isEqualTo("Jane's birthday (30)")
}
@Test
fun `title drops an empty age parenthesis and tidies spacing`() {
assertThat(renderSpecialDateTitle("{name}'s birthday ({age})", "Jane", null))
.isEqualTo("Jane's birthday")
}
}