From 0e4c47febe750062c26ce9bd05c3a7242cbcf15b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 8 Jun 2026 17:40:37 +0200 Subject: [PATCH] data: add ColumnReader abstraction + Cursor.toCalendarSource mapper Deviation from Plan 02: the JVM mockable-android.jar stubs every Cursor method even with isReturnDefaultValues=true (returns null/0 regardless of the underlying MatrixCursor backing). Introduce an internal ColumnReader interface so mappers stay pure-Kotlin and JVM-testable via MapColumnReader, while production reads through CursorColumnReader. --- .../calendula/data/calendar/CalendarMapper.kt | 13 ++++ .../calendula/data/calendar/ColumnReader.kt | 22 ++++++ .../data/calendar/CalendarMapperTest.kt | 68 +++++++++++++++++++ .../data/calendar/MapColumnReader.kt | 29 ++++++++ 4 files changed, 132 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ColumnReader.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/MapColumnReader.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt new file mode 100644 index 0000000..7531308 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapper.kt @@ -0,0 +1,13 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import de.jeanlucmakiola.calendula.domain.CalendarSource + +internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource( + id = getLong(CalendarProjection.IDX_ID), + displayName = getString(CalendarProjection.IDX_DISPLAY_NAME) + ?: Fallbacks.UNNAMED_CALENDAR, + accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(), + accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty(), + color = getInt(CalendarProjection.IDX_COLOR), + isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0, +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ColumnReader.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ColumnReader.kt new file mode 100644 index 0000000..12286e3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ColumnReader.kt @@ -0,0 +1,22 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import android.database.Cursor + +/** + * Read-only view over a single row's columns by index. Lets the mappers work + * on pure-Kotlin test fixtures (MapColumnReader) on the JVM, while the + * production path adapts an Android Cursor row via CursorColumnReader. + */ +internal interface ColumnReader { + fun getLong(index: Int): Long + fun getString(index: Int): String? + fun getInt(index: Int): Int + fun isNull(index: Int): Boolean +} + +internal class CursorColumnReader(private val cursor: Cursor) : ColumnReader { + override fun getLong(index: Int): Long = cursor.getLong(index) + override fun getString(index: Int): String? = cursor.getString(index) + override fun getInt(index: Int): Int = cursor.getInt(index) + override fun isNull(index: Int): Boolean = cursor.isNull(index) +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt new file mode 100644 index 0000000..4aaf42b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarMapperTest.kt @@ -0,0 +1,68 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class CalendarMapperTest { + + private fun reader( + id: Long = 1L, + displayName: String? = "Cal", + accountName: String? = "x@y", + accountType: String? = "LOCAL", + color: Int = 0, + visible: Int = 1, + ): MapColumnReader = MapColumnReader( + CalendarProjection.IDX_ID to id, + CalendarProjection.IDX_DISPLAY_NAME to displayName, + CalendarProjection.IDX_ACCOUNT_NAME to accountName, + CalendarProjection.IDX_ACCOUNT_TYPE to accountType, + CalendarProjection.IDX_COLOR to color, + CalendarProjection.IDX_VISIBLE to visible, + ) + + @Test + fun `happy path maps all six columns`() { + val src = reader( + id = 42L, + displayName = "Work", + accountName = "x@y", + accountType = "com.google", + color = 0xFF112233.toInt(), + visible = 1, + ).toCalendarSource() + assertThat(src).isEqualTo( + de.jeanlucmakiola.calendula.domain.CalendarSource( + id = 42L, + displayName = "Work", + accountName = "x@y", + accountType = "com.google", + color = 0xFF112233.toInt(), + isVisibleInSystem = true, + ) + ) + } + + @Test + fun `null displayName falls back to placeholder`() { + val src = reader(displayName = null).toCalendarSource() + assertThat(src.displayName).isEqualTo(Fallbacks.UNNAMED_CALENDAR) + } + + @Test + fun `visible flag 0 maps to false`() { + assertThat(reader(visible = 0).toCalendarSource().isVisibleInSystem).isFalse() + } + + @Test + fun `visible flag 1 maps to true`() { + assertThat(reader(visible = 1).toCalendarSource().isVisibleInSystem).isTrue() + } + + @Test + fun `null accountName and accountType coerce to empty string`() { + val src = reader(accountName = null, accountType = null).toCalendarSource() + assertThat(src.accountName).isEqualTo("") + assertThat(src.accountType).isEqualTo("") + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/MapColumnReader.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/MapColumnReader.kt new file mode 100644 index 0000000..c3dc756 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/MapColumnReader.kt @@ -0,0 +1,29 @@ +package de.jeanlucmakiola.calendula.data.calendar + +/** + * Test-only ColumnReader. Backed by a Map; any missing index is + * treated as null. Numeric getters coerce via toLong/toInt; non-numeric values + * yield zero (matching Android Cursor behavior for type-mismatched reads). + */ +internal class MapColumnReader(values: Map) : ColumnReader { + + private val data: Map = values + + constructor(vararg pairs: Pair) : this(pairs.toMap()) + + override fun getLong(index: Int): Long = when (val v = data[index]) { + is Number -> v.toLong() + is String -> v.toLongOrNull() ?: 0L + else -> 0L + } + + override fun getString(index: Int): String? = data[index]?.toString() + + override fun getInt(index: Int): Int = when (val v = data[index]) { + is Number -> v.toInt() + is String -> v.toIntOrNull() ?: 0 + else -> 0 + } + + override fun isNull(index: Int): Boolean = data[index] == null +}