diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 828172a..1cc3cb2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,9 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) + // kapt is already on the classpath via the Kotlin Gradle plugin — apply it + // without a version (declaring one conflicts: "already on the classpath"). + id("org.jetbrains.kotlin.kapt") } // --------------------------------------------------------------------------- @@ -160,8 +163,8 @@ android { applicationId = "com.pinakes.app" minSdk = 26 targetSdk = 35 - versionCode = 3 - versionName = "1.1.1" + versionCode = 4 + versionName = "1.2.0" vectorDrawables { useSupportLibrary = true @@ -218,6 +221,13 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + + testOptions { + unitTests { + // Robolectric needs the merged Android resources for in-JVM Room/DAO tests. + isIncludeAndroidResources = true + } + } } // Wire the generated res directory into every variant. addGeneratedSourceDirectory @@ -267,4 +277,17 @@ dependencies { // Per-app language preferences (AppCompatDelegate.setApplicationLocales) + // autoStoreLocales backport for API < 33. implementation(libs.androidx.appcompat) + + // Local cache: Room (offline catalog) + app-open refresh via the process lifecycle. + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + kapt(libs.androidx.room.compiler) + implementation(libs.androidx.lifecycle.process) + + // Unit tests (JVM + Robolectric for Room DAO). + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.room.testing) + testImplementation(libs.androidx.test.core) } diff --git a/app/src/main/java/com/pinakes/app/PinakesApplication.kt b/app/src/main/java/com/pinakes/app/PinakesApplication.kt index 3898840..43cdfc0 100644 --- a/app/src/main/java/com/pinakes/app/PinakesApplication.kt +++ b/app/src/main/java/com/pinakes/app/PinakesApplication.kt @@ -1,16 +1,54 @@ package com.pinakes.app import android.app.Application +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.disk.DiskCache import com.pinakes.app.di.ServiceLocator +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch -/** Application entry point; owns the single [ServiceLocator]. */ -class PinakesApplication : Application() { +/** Application entry point; owns the single [ServiceLocator] and the Coil image loader. */ +class PinakesApplication : Application(), ImageLoaderFactory { lateinit var services: ServiceLocator private set + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + override fun onCreate() { super.onCreate() services = ServiceLocator(this) + + // Refresh the cached catalog every time the app comes to the foreground, so the + // offline catalog stays current without a network round-trip on every screen. + ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + if (!services.session.isLoggedIn()) return + appScope.launch { services.catalogRepository.refreshCatalog() } + } + }) } + + /** + * App-wide Coil loader with a persistent 256 MB disk cache that ignores server cache + * headers, so book covers are downloaded once and reused across sessions instead of + * being re-fetched on every screen / app open. + */ + override fun newImageLoader(): ImageLoader = + ImageLoader.Builder(this) + .crossfade(true) + .respectCacheHeaders(false) + .diskCache { + DiskCache.Builder() + .directory(cacheDir.resolve("image_cache")) + .maxSizeBytes(256L * 1024 * 1024) + .build() + } + .build() } diff --git a/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt b/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt new file mode 100644 index 0000000..2995974 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt @@ -0,0 +1,26 @@ +package com.pinakes.app.data.local + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +/** Local cache database. The cache is disposable, so destructive migration is fine. */ +@Database(entities = [CachedBook::class], version = 1, exportSchema = false) +abstract class AppDatabase : RoomDatabase() { + abstract fun catalogDao(): CatalogDao + + companion object { + @Volatile + private var INSTANCE: AppDatabase? = null + + fun get(context: Context): AppDatabase = + INSTANCE ?: synchronized(this) { + INSTANCE ?: Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "pinakes-cache.db", + ).fallbackToDestructiveMigration().build().also { INSTANCE = it } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/data/local/CachedBook.kt b/app/src/main/java/com/pinakes/app/data/local/CachedBook.kt new file mode 100644 index 0000000..9768eec --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/local/CachedBook.kt @@ -0,0 +1,64 @@ +package com.pinakes.app.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.pinakes.app.data.model.BookSummary + +/** + * A catalog list item cached locally so the catalog renders instantly and works + * offline (no per-open network round-trip for the list or its covers). Mirrors + * [BookSummary]; [position] preserves the server's ordering for the snapshot. + */ +@Entity(tableName = "cached_books") +data class CachedBook( + @PrimaryKey val id: Int, + val position: Int, + val title: String, + val subtitle: String?, + val author: String?, + val publisher: String?, + val genre: String?, + val year: Int?, + val language: String?, + val mediaType: String?, + val isbn13: String?, + val coverUrl: String?, + val copiesTotal: Int, + val copiesAvailable: Int, + val loanableNow: Boolean, +) + +fun CachedBook.toSummary(): BookSummary = BookSummary( + id = id, + title = title, + subtitle = subtitle, + author = author, + publisher = publisher, + genre = genre, + year = year, + language = language, + mediaType = mediaType, + isbn13 = isbn13, + coverUrl = coverUrl, + copiesTotal = copiesTotal, + copiesAvailable = copiesAvailable, + loanableNow = loanableNow, +) + +fun BookSummary.toCached(position: Int): CachedBook = CachedBook( + id = id, + position = position, + title = title, + subtitle = subtitle, + author = author, + publisher = publisher, + genre = genre, + year = year, + language = language, + mediaType = mediaType, + isbn13 = isbn13, + coverUrl = coverUrl, + copiesTotal = copiesTotal, + copiesAvailable = copiesAvailable, + loanableNow = loanableNow, +) diff --git a/app/src/main/java/com/pinakes/app/data/local/CatalogDao.kt b/app/src/main/java/com/pinakes/app/data/local/CatalogDao.kt new file mode 100644 index 0000000..bd5adbb --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/local/CatalogDao.kt @@ -0,0 +1,32 @@ +package com.pinakes.app.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow + +@Dao +interface CatalogDao { + + /** Reactive cached catalog snapshot, in the server's order. */ + @Query("SELECT * FROM cached_books ORDER BY position ASC") + fun observeAll(): Flow> + + @Query("SELECT COUNT(*) FROM cached_books") + suspend fun count(): Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(books: List) + + @Query("DELETE FROM cached_books") + suspend fun clear() + + /** Atomically replace the whole cache with a fresh catalog snapshot. */ + @Transaction + suspend fun replaceAll(books: List) { + clear() + insertAll(books) + } +} diff --git a/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt index 6ea7afc..7aab1d7 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt @@ -1,5 +1,8 @@ package com.pinakes.app.data.repository +import com.pinakes.app.data.local.CatalogDao +import com.pinakes.app.data.local.toCached +import com.pinakes.app.data.local.toSummary import com.pinakes.app.data.model.AvailabilityCalendar import com.pinakes.app.data.model.BookDetail import com.pinakes.app.data.model.BookSummary @@ -9,6 +12,8 @@ import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.data.network.NetworkModule import com.pinakes.app.data.network.apiCall import com.pinakes.app.data.network.apiResponse +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map /** Filters for a catalog search; nulls are omitted from the query. */ data class SearchFilters( @@ -33,11 +38,39 @@ data class SearchPage( * Catalog browsing: cursor-paginated search, book detail (with ETag/304 caching) and the * genre tree. The ETag cache lets a re-fetch of the same book reuse the last payload on 304. */ -class CatalogRepository(private val network: NetworkModule) { +class CatalogRepository( + private val network: NetworkModule, + private val catalogDao: CatalogDao, +) { // Small in-memory ETag cache for book detail: id -> (etag, payload). private val detailCache = HashMap>() + /** + * Reactive cached catalog snapshot (offline-first). Emits whatever Room holds — + * including offline — so the UI can render instantly without a network round-trip + * for the list or, via Coil's disk cache, its covers. Empty until the first refresh. + */ + fun observeCachedCatalog(): Flow> = + catalogDao.observeAll().map { rows -> rows.map { it.toSummary() } } + + /** True once a catalog snapshot has been cached at least once. */ + suspend fun hasCachedCatalog(): Boolean = catalogDao.count() > 0 + + /** + * Refresh the cached catalog from the network (first page, unfiltered) and replace + * the Room snapshot atomically. On network failure the existing cache is kept, so a + * refresh-on-open that fails never wipes the offline catalog. + */ + suspend fun refreshCatalog(limit: Int = 40): ApiResult = + when (val res = search(SearchFilters(), limit = limit)) { + is ApiResult.Success -> { + catalogDao.replaceAll(res.data.items.mapIndexed { i, b -> b.toCached(i) }) + ApiResult.Success(Unit, res.meta) + } + is ApiResult.Failure -> res + } + suspend fun search( filters: SearchFilters, cursor: String? = null, diff --git a/app/src/main/java/com/pinakes/app/di/ServiceLocator.kt b/app/src/main/java/com/pinakes/app/di/ServiceLocator.kt index bcb24d8..15acb81 100644 --- a/app/src/main/java/com/pinakes/app/di/ServiceLocator.kt +++ b/app/src/main/java/com/pinakes/app/di/ServiceLocator.kt @@ -1,6 +1,7 @@ package com.pinakes.app.di import android.content.Context +import com.pinakes.app.data.local.AppDatabase import com.pinakes.app.data.network.NetworkModule import com.pinakes.app.data.repository.AuthRepository import com.pinakes.app.data.repository.CatalogRepository @@ -30,8 +31,11 @@ class ServiceLocator(context: Context) { val network: NetworkModule = NetworkModule(session) + /** Local cache DB (offline catalog snapshot). */ + val database: AppDatabase = AppDatabase.get(context.applicationContext) + val authRepository: AuthRepository by lazy { AuthRepository(network, session, features) } - val catalogRepository: CatalogRepository by lazy { CatalogRepository(network) } + val catalogRepository: CatalogRepository by lazy { CatalogRepository(network, database.catalogDao()) } val libraryRepository: LibraryRepository by lazy { LibraryRepository(network) } val wishlistRepository: WishlistRepository by lazy { WishlistRepository(network) } val profileRepository: ProfileRepository by lazy { ProfileRepository(network) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt index e13df7a..0b66df1 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt @@ -6,11 +6,11 @@ import androidx.lifecycle.viewModelScope import com.pinakes.app.data.model.BookSummary import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.repository.CatalogRepository -import com.pinakes.app.data.repository.SearchFilters import com.pinakes.app.data.store.SessionStore import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -34,24 +34,48 @@ class HomeViewModel( ) val state: StateFlow = _state.asStateFlow() - init { load() } + init { + observeCache() + refresh() + } - fun load() { - _state.update { it.copy(loading = true, error = null) } + /** + * Offline-first: render the "Available now" shelf from the locally-cached catalog + * snapshot immediately (works with no network), filtering to currently-loanable + * copies. The shelf updates automatically when [refresh] replaces the cache. + */ + private fun observeCache() { viewModelScope.launch { - // "Available now" shelf: full catalog filtered to currently-loanable copies. - when (val res = catalog.search(SearchFilters(availableOnly = true), limit = 20)) { - is ApiResult.Success -> _state.update { - it.copy(available = res.data.items, loading = false, error = null) - } - is ApiResult.Failure -> _state.update { - it.copy(loading = false, error = res.message.takeIf { m -> m.isNotBlank() }) + catalog.observeCachedCatalog().collectLatest { books -> + val available = books.filter { it.available } + _state.update { it.copy(available = available, loading = false, error = null) } + } + } + } + + /** + * Pull a fresh catalog snapshot from the network into the cache. Called on init and + * on every app foreground. A failure only surfaces an error when there is nothing + * cached to show — otherwise the cached catalog stays on screen. + */ + fun refresh() { + viewModelScope.launch { + when (val res = catalog.refreshCatalog()) { + is ApiResult.Success -> _state.update { it.copy(loading = false, error = null) } + is ApiResult.Failure -> { + val hasCache = catalog.hasCachedCatalog() + _state.update { + it.copy( + loading = false, + error = if (hasCache) null else res.message.takeIf { m -> m.isNotBlank() }, + ) + } } } } } - fun retry() = load() + fun retry() = refresh() class Factory( private val catalog: CatalogRepository, diff --git a/app/src/test/java/com/pinakes/app/BookSummaryTest.kt b/app/src/test/java/com/pinakes/app/BookSummaryTest.kt new file mode 100644 index 0000000..4b7b625 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/BookSummaryTest.kt @@ -0,0 +1,31 @@ +package com.pinakes.app + +import com.pinakes.app.data.model.BookSummary +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Derived availability used by the catalog/home shelves (touched area: offline catalog). */ +class BookSummaryTest { + + @Test fun availableWhenLoanableNow() { + assertTrue(BookSummary(id = 1, loanableNow = true, copiesAvailable = 0).available) + } + + @Test fun availableWhenCopiesFree() { + assertTrue(BookSummary(id = 1, loanableNow = false, copiesAvailable = 2).available) + } + + @Test fun notAvailableWhenNothingFree() { + assertFalse(BookSummary(id = 1, loanableNow = false, copiesAvailable = 0).available) + } + + @Test fun authorsLabelIsEmptyWhenNull() { + assertEquals("", BookSummary(id = 1, author = null).authorsLabel) + } + + @Test fun authorsLabelEchoesAuthor() { + assertEquals("Virginia Woolf", BookSummary(id = 1, author = "Virginia Woolf").authorsLabel) + } +} diff --git a/app/src/test/java/com/pinakes/app/CachedBookMapperTest.kt b/app/src/test/java/com/pinakes/app/CachedBookMapperTest.kt new file mode 100644 index 0000000..5b9fe40 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/CachedBookMapperTest.kt @@ -0,0 +1,40 @@ +package com.pinakes.app + +import com.pinakes.app.data.local.toCached +import com.pinakes.app.data.local.toSummary +import com.pinakes.app.data.model.BookSummary +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Entity ↔ summary mapping that backs the offline catalog cache. */ +class CachedBookMapperTest { + + private val sample = BookSummary( + id = 7, + title = "Orlando", + subtitle = "A Biography", + author = "Virginia Woolf", + publisher = "Hogarth", + genre = "Romanzo", + year = 1928, + language = "en", + mediaType = "book", + isbn13 = "9780156701600", + coverUrl = "https://lib.example.org/covers/7.jpg", + copiesTotal = 3, + copiesAvailable = 1, + loanableNow = true, + ) + + @Test fun roundTripPreservesAllFields() { + assertEquals(sample, sample.toCached(0).toSummary()) + } + + @Test fun cachedKeepsPosition() { + assertEquals(5, sample.toCached(5).position) + } + + @Test fun cachedKeepsPrimaryKey() { + assertEquals(7, sample.toCached(0).id) + } +} diff --git a/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt b/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt new file mode 100644 index 0000000..c14e017 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt @@ -0,0 +1,87 @@ +package com.pinakes.app + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.pinakes.app.data.local.AppDatabase +import com.pinakes.app.data.local.CachedBook +import com.pinakes.app.data.local.CatalogDao +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** The offline catalog cache (Room). Runs on the JVM via Robolectric's SQLite. */ +@RunWith(RobolectricTestRunner::class) +// API 34: Robolectric 4.13 supports up to API 34 (app compiles against 35). +// Plain Application: skip PinakesApplication.onCreate() (it builds EncryptedSharedPreferences +// via the AndroidKeyStore, which isn't available under Robolectric). +@Config(sdk = [34], application = android.app.Application::class) +class CatalogDaoTest { + + private lateinit var db: AppDatabase + private lateinit var dao: CatalogDao + + private fun book(id: Int, position: Int, title: String = "Book $id") = CachedBook( + id = id, position = position, title = title, subtitle = null, author = null, + publisher = null, genre = null, year = null, language = null, mediaType = null, + isbn13 = null, coverUrl = null, copiesTotal = 1, copiesAvailable = 1, loanableNow = true, + ) + + @Before + fun setup() { + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + AppDatabase::class.java, + ).allowMainThreadQueries().build() + dao = db.catalogDao() + } + + @After + fun teardown() = db.close() + + @Test + fun emptyByDefault() = runBlocking { + assertEquals(0, dao.count()) + assertEquals(emptyList(), dao.observeAll().first()) + } + + @Test + fun insertAndObserve() = runBlocking { + dao.insertAll(listOf(book(1, 0), book(2, 1))) + assertEquals(2, dao.count()) + assertEquals(2, dao.observeAll().first().size) + } + + @Test + fun observeIsOrderedByPosition() = runBlocking { + dao.insertAll(listOf(book(2, 1), book(1, 0), book(3, 2))) + assertEquals(listOf(1, 2, 3), dao.observeAll().first().map { it.id }) + } + + @Test + fun insertReplacesOnConflict() = runBlocking { + dao.insertAll(listOf(book(1, 0, title = "old"))) + dao.insertAll(listOf(book(1, 0, title = "new"))) + assertEquals("new", dao.observeAll().first().single().title) + } + + @Test + fun replaceAllSwapsTheSnapshotAtomically() = runBlocking { + dao.insertAll(listOf(book(1, 0), book(2, 1))) + dao.replaceAll(listOf(book(9, 0))) + assertEquals(listOf(9), dao.observeAll().first().map { it.id }) + assertEquals(1, dao.count()) + } + + @Test + fun clearEmptiesTheCache() = runBlocking { + dao.insertAll(listOf(book(1, 0))) + dao.clear() + assertEquals(0, dao.count()) + } +} diff --git a/app/src/test/java/com/pinakes/app/NetworkUrlTest.kt b/app/src/test/java/com/pinakes/app/NetworkUrlTest.kt new file mode 100644 index 0000000..8f10f87 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/NetworkUrlTest.kt @@ -0,0 +1,50 @@ +package com.pinakes.app + +import com.pinakes.app.data.network.NetworkModule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Pure URL-derivation helpers used by onboarding (touched area: networking). */ +class NetworkUrlTest { + + @Test fun addsHttpsAndApiV1() { + assertEquals("https://lib.example.org/api/v1/", NetworkModule.deriveApiBaseUrl("lib.example.org")) + } + + @Test fun keepsExplicitHttpScheme() { + assertEquals("http://10.0.2.2:8081/api/v1/", NetworkModule.deriveApiBaseUrl("http://10.0.2.2:8081")) + } + + @Test fun stripsTrailingSlash() { + assertEquals("https://lib.example.org/api/v1/", NetworkModule.deriveApiBaseUrl("https://lib.example.org/")) + } + + @Test fun stripsUserSuppliedApiV1Suffix() { + assertEquals("https://lib.example.org/api/v1/", NetworkModule.deriveApiBaseUrl("https://lib.example.org/api/v1")) + } + + @Test fun stripsUserSuppliedApiSuffix() { + assertEquals("https://lib.example.org/api/v1/", NetworkModule.deriveApiBaseUrl("https://lib.example.org/api")) + } + + @Test fun blankStaysBlank() { + assertEquals("", NetworkModule.deriveApiBaseUrl(" ")) + } + + @Test fun originDropsApiV1() { + assertEquals("https://lib.example.org", NetworkModule.deriveOrigin("lib.example.org/api/v1")) + } + + @Test fun httpsAlwaysAllowed() { + assertTrue(NetworkModule.isTransportAllowed("https://lib.example.org/api/v1/")) + } + + @Test fun httpAllowedOnlyForLoopback() { + assertTrue(NetworkModule.isTransportAllowed("http://10.0.2.2:8081/api/v1/")) + assertTrue(NetworkModule.isTransportAllowed("http://localhost/api/v1/")) + assertTrue(NetworkModule.isTransportAllowed("http://127.0.0.1/api/v1/")) + assertFalse(NetworkModule.isTransportAllowed("http://lib.example.org/api/v1/")) + } +} diff --git a/app/src/test/java/com/pinakes/app/StatusMappingTest.kt b/app/src/test/java/com/pinakes/app/StatusMappingTest.kt new file mode 100644 index 0000000..c073922 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/StatusMappingTest.kt @@ -0,0 +1,40 @@ +package com.pinakes.app + +import com.pinakes.app.ui.common.StatusMapping +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Which reservation statuses expose the "Cancel" action (touched area: reservation UX). */ +class StatusMappingTest { + + @Test fun pendingIsCancellable() { + assertTrue(StatusMapping.isReservationCancellable("pending")) + assertTrue(StatusMapping.isReservationCancellable("pendente")) + assertTrue(StatusMapping.isReservationCancellable("in_attesa")) + } + + @Test fun prenotatoIsCancellable() { + assertTrue(StatusMapping.isReservationCancellable("prenotato")) + } + + @Test fun activeIsCancellable() { + assertTrue(StatusMapping.isReservationCancellable("attiva")) + assertTrue(StatusMapping.isReservationCancellable("active")) + } + + @Test fun readyForPickupIsNotCancellable() { + // Approved/ready reservations can't be self-cancelled from the app. + assertFalse(StatusMapping.isReservationCancellable("da_ritirare")) + } + + @Test fun cancelledIsNotCancellable() { + assertFalse(StatusMapping.isReservationCancellable("annullata")) + assertFalse(StatusMapping.isReservationCancellable("scaduto")) + } + + @Test fun unknownStatusIsNotCancellable() { + assertFalse(StatusMapping.isReservationCancellable("whatever")) + assertFalse(StatusMapping.isReservationCancellable("")) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e7cf712..d3b5e88 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,11 @@ securityCrypto = "1.1.0-alpha06" datastore = "1.1.1" appcompat = "1.7.0" media3 = "1.4.1" +room = "2.6.1" +junit = "4.13.2" +coroutinesTest = "1.8.1" +robolectric = "4.13" +testCore = "1.6.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -41,6 +46,15 @@ androidx-datastore-preferences = { group = "androidx.datastore", name = "datasto androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } +androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "testCore" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }