From 40b531fa521ce89bfddd09d689fabc07d45ac8e6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 8 Jun 2026 17:41:29 +0200 Subject: [PATCH] =?UTF-8?q?data:=20add=20ColumnReader.toEventInstance()=20?= =?UTF-8?q?with=20defensive=20validation=20(=C2=A78)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../calendula/data/calendar/InstanceMapper.kt | 41 ++++++++ .../data/calendar/InstanceMapperTest.kt | 93 +++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt new file mode 100644 index 0000000..263300b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt @@ -0,0 +1,41 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import android.util.Log +import de.jeanlucmakiola.calendula.domain.EventInstance + +private const val TAG = "InstanceMapper" + +internal fun ColumnReader.toEventInstance(): EventInstance? { + val begin = getLong(InstanceProjection.IDX_BEGIN) + val end = getLong(InstanceProjection.IDX_END) + + if (begin < 0L) { + Log.w(TAG, "Dropping row with negative begin=$begin") + return null + } + if (end < begin) { + Log.w(TAG, "Dropping row with end=$end < begin=$begin") + return null + } + + val rawTitle = getString(InstanceProjection.IDX_TITLE) + val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle + + val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) { + getInt(InstanceProjection.IDX_CALENDAR_COLOR) + } else { + getInt(InstanceProjection.IDX_EVENT_COLOR) + } + + return EventInstance( + instanceId = getLong(InstanceProjection.IDX_INSTANCE_ID), + eventId = getLong(InstanceProjection.IDX_EVENT_ID), + calendarId = getLong(InstanceProjection.IDX_CALENDAR_ID), + title = title, + start = begin.toKotlinInstantFromEpochMillis(), + end = end.toKotlinInstantFromEpochMillis(), + isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0, + color = color, + location = getString(InstanceProjection.IDX_LOCATION), + ) +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt new file mode 100644 index 0000000..a546014 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt @@ -0,0 +1,93 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import com.google.common.truth.Truth.assertThat +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +class InstanceMapperTest { + + private fun reader( + instanceId: Long = 10L, + eventId: Long = 1L, + calendarId: Long = 1L, + title: String? = "Meet", + begin: Long = 1_000_000_000L, + end: Long = 1_000_003_600L, + allDay: Int = 0, + eventColor: Any? = null, + calendarColor: Int = 0xFFAABBCC.toInt(), + location: String? = null, + ): MapColumnReader = MapColumnReader( + InstanceProjection.IDX_INSTANCE_ID to instanceId, + InstanceProjection.IDX_EVENT_ID to eventId, + InstanceProjection.IDX_CALENDAR_ID to calendarId, + InstanceProjection.IDX_TITLE to title, + InstanceProjection.IDX_BEGIN to begin, + InstanceProjection.IDX_END to end, + InstanceProjection.IDX_ALL_DAY to allDay, + InstanceProjection.IDX_EVENT_COLOR to eventColor, + InstanceProjection.IDX_CALENDAR_COLOR to calendarColor, + InstanceProjection.IDX_LOCATION to location, + ) + + @Test + fun `happy path - non-allday event`() { + val inst = reader().toEventInstance() + assertThat(inst).isNotNull() + assertThat(inst!!.title).isEqualTo("Meet") + assertThat(inst.isAllDay).isFalse() + assertThat(inst.start).isEqualTo(Instant.fromEpochMilliseconds(1_000_000_000L)) + assertThat(inst.end).isEqualTo(Instant.fromEpochMilliseconds(1_000_003_600L)) + } + + @Test + fun `event color falls back to calendar color when null`() { + val inst = reader(eventColor = null, calendarColor = 0xFF112233.toInt()).toEventInstance() + assertThat(inst!!.color).isEqualTo(0xFF112233.toInt()) + } + + @Test + fun `event color wins over calendar color when present`() { + val inst = reader( + eventColor = 0xFFDEADBE.toInt(), + calendarColor = 0xFF112233.toInt(), + ).toEventInstance() + assertThat(inst!!.color).isEqualTo(0xFFDEADBE.toInt()) + } + + @Test + fun `null title falls back to placeholder`() { + val inst = reader(title = null).toEventInstance() + assertThat(inst!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT) + } + + @Test + fun `empty title falls back to placeholder`() { + val inst = reader(title = "").toEventInstance() + assertThat(inst!!.title).isEqualTo(Fallbacks.UNTITLED_EVENT) + } + + @Test + fun `dtend before dtstart drops the row`() { + val inst = reader(begin = 2000L, end = 1000L).toEventInstance() + assertThat(inst).isNull() + } + + @Test + fun `dtstart before unix epoch drops the row`() { + val inst = reader(begin = -1L, end = 1000L).toEventInstance() + assertThat(inst).isNull() + } + + @Test + fun `all-day flag 1 maps to true`() { + val inst = reader(allDay = 1).toEventInstance() + assertThat(inst!!.isAllDay).isTrue() + } + + @Test + fun `location passes through when present`() { + val inst = reader(location = "Berlin").toEventInstance() + assertThat(inst!!.location).isEqualTo("Berlin") + } +}