From ed21612aeda1345fe35b6474393482afe045245e Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 30 Jun 2026 15:42:47 +0200 Subject: [PATCH] test: +29 unit tests across the recently-touched areas Covers the offline-cache + sync + status/date logic we just added/changed; the suite goes from 37 to 66 green. - CatalogDaoEdgeTest (5): empty replaceAll clears, position ordering with non-sequential positions, snapshot swap, count-after-clear, 50-item batch. - CachedBookMapperEdgeTest (5): all-null and fully-populated round-trips, position vs id independence, availability-flag preservation. - BookSummaryEdgeTest (4): availability boundaries (1 copy, 0, negative), authorsLabel. - StatusMappingMoreTest (7): loan()/reservation() colour buckets + title-cased fallback, loanGroup() priority ordering and default. - DateFormatMoreTest (3): offset timestamp, unparseable dateTime passthrough, todayIso. - NetworkUrlMoreTest (4): explicit port, origin derivation, http loopback/emulator allowed vs public http rejected. - CatalogSyncWorkerTest (1): WorkManager worker degrades to a successful no-op under a non-Pinakes Application context (via work-testing TestListenableWorkerBuilder). Adds androidx.work:work-testing as a test dependency. Verified: testDebugUnitTest 66/66 green. --- app/build.gradle.kts | 1 + .../com/pinakes/app/BookSummaryEdgeTest.kt | 30 ++++++++ .../pinakes/app/CachedBookMapperEdgeTest.kt | 49 ++++++++++++ .../com/pinakes/app/CatalogDaoEdgeTest.kt | 75 +++++++++++++++++++ .../com/pinakes/app/CatalogSyncWorkerTest.kt | 29 +++++++ .../com/pinakes/app/DateFormatMoreTest.kt | 28 +++++++ .../com/pinakes/app/NetworkUrlMoreTest.kt | 33 ++++++++ .../com/pinakes/app/StatusMappingMoreTest.kt | 56 ++++++++++++++ gradle/libs.versions.toml | 1 + 9 files changed, 302 insertions(+) create mode 100644 app/src/test/java/com/pinakes/app/BookSummaryEdgeTest.kt create mode 100644 app/src/test/java/com/pinakes/app/CachedBookMapperEdgeTest.kt create mode 100644 app/src/test/java/com/pinakes/app/CatalogDaoEdgeTest.kt create mode 100644 app/src/test/java/com/pinakes/app/CatalogSyncWorkerTest.kt create mode 100644 app/src/test/java/com/pinakes/app/DateFormatMoreTest.kt create mode 100644 app/src/test/java/com/pinakes/app/NetworkUrlMoreTest.kt create mode 100644 app/src/test/java/com/pinakes/app/StatusMappingMoreTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c3cb013..27de57d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -315,4 +315,5 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.androidx.room.testing) testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.work.testing) } diff --git a/app/src/test/java/com/pinakes/app/BookSummaryEdgeTest.kt b/app/src/test/java/com/pinakes/app/BookSummaryEdgeTest.kt new file mode 100644 index 0000000..1202be7 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/BookSummaryEdgeTest.kt @@ -0,0 +1,30 @@ +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 + +/** Boundary cases for the derived availability/labels used by catalog & home shelves. */ +class BookSummaryEdgeTest { + + @Test fun availableWhenBothLoanableAndCopies() { + assertTrue(BookSummary(id = 1, loanableNow = true, copiesAvailable = 2).available) + } + + @Test fun availableAtExactlyOneCopyBoundary() { + assertTrue(BookSummary(id = 1, loanableNow = false, copiesAvailable = 1).available) + assertFalse(BookSummary(id = 1, loanableNow = false, copiesAvailable = 0).available) + } + + @Test fun negativeCopiesAreNotAvailableWhenNotLoanable() { + // Defensive: a malformed negative count must not read as available. + assertFalse(BookSummary(id = 1, loanableNow = false, copiesAvailable = -1).available) + } + + @Test fun authorsLabelEchoesMultiWordAuthor() { + assertEquals("Italo Calvino", BookSummary(id = 1, author = "Italo Calvino").authorsLabel) + assertEquals("", BookSummary(id = 1, author = null).authorsLabel) + } +} diff --git a/app/src/test/java/com/pinakes/app/CachedBookMapperEdgeTest.kt b/app/src/test/java/com/pinakes/app/CachedBookMapperEdgeTest.kt new file mode 100644 index 0000000..2e6ec75 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/CachedBookMapperEdgeTest.kt @@ -0,0 +1,49 @@ +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.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Extra round-trip/edge cases for the offline-cache entity↔summary mapping (touched: #9). */ +class CachedBookMapperEdgeTest { + + @Test fun allOptionalNullsRoundTrip() { + val s = BookSummary(id = 1, title = "Bare") // every optional left null/default + assertEquals(s, s.toCached(0).toSummary()) + } + + @Test fun fullyPopulatedRoundTrips() { + val s = BookSummary( + id = 42, title = "T", subtitle = "Sub", author = "A", publisher = "P", genre = "G", + year = 2000, language = "it", mediaType = "audiobook", isbn13 = "9781234567897", + coverUrl = "https://x/y.jpg", copiesTotal = 5, copiesAvailable = 2, loanableNow = false, + ) + assertEquals(s, s.toCached(3).toSummary()) + } + + @Test fun positionIsCarriedAndIndependentOfId() { + val s = BookSummary(id = 7, title = "X") + assertEquals(0, s.toCached(0).position) + assertEquals(999, s.toCached(999).position) + assertEquals(7, s.toCached(0).id) // id stays the book id, not the position + } + + @Test fun availabilityFlagsSurviveTheRoundTrip() { + val notLoanableButCopies = BookSummary(id = 1, loanableNow = false, copiesAvailable = 3) + val back = notLoanableButCopies.toCached(0).toSummary() + assertFalse(back.loanableNow) + assertEquals(3, back.copiesAvailable) + assertTrue(back.available) // derived: copies > 0 + } + + @Test fun loanableNowTrueSurvives() { + val loanable = BookSummary(id = 2, loanableNow = true, copiesAvailable = 0) + val back = loanable.toCached(1).toSummary() + assertTrue(back.loanableNow) + assertTrue(back.available) + } +} diff --git a/app/src/test/java/com/pinakes/app/CatalogDaoEdgeTest.kt b/app/src/test/java/com/pinakes/app/CatalogDaoEdgeTest.kt new file mode 100644 index 0000000..2ea000b --- /dev/null +++ b/app/src/test/java/com/pinakes/app/CatalogDaoEdgeTest.kt @@ -0,0 +1,75 @@ +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 + +/** Extra edge cases for the offline-catalog Room cache (touched: offline cache #9). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class) +class CatalogDaoEdgeTest { + + private lateinit var db: AppDatabase + private lateinit var dao: CatalogDao + + private fun book(id: Int, position: Int) = CachedBook( + id = id, position = position, title = "Book $id", 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 replaceAllWithEmptyListClears() = runBlocking { + dao.insertAll(listOf(book(1, 0), book(2, 1))) + dao.replaceAll(emptyList()) + assertEquals(0, dao.count()) + assertEquals(emptyList(), dao.observeAll().first()) + } + + @Test fun observeOrdersByPositionNotByInsertionOrId() = runBlocking { + // Insert out of order with non-sequential positions; observeAll sorts by position. + dao.insertAll(listOf(book(10, 5), book(20, 2), book(30, 9))) + assertEquals(listOf(20, 10, 30), dao.observeAll().first().map { it.id }) + } + + @Test fun replaceAllTwiceKeepsOnlyTheLatestSnapshot() = runBlocking { + dao.replaceAll(listOf(book(1, 0), book(2, 1), book(3, 2))) + dao.replaceAll(listOf(book(7, 0))) + assertEquals(listOf(7), dao.observeAll().first().map { it.id }) + assertEquals(1, dao.count()) + } + + @Test fun countReflectsClear() = runBlocking { + dao.insertAll(listOf(book(1, 0), book(2, 1), book(3, 2))) + assertEquals(3, dao.count()) + dao.clear() + assertEquals(0, dao.count()) + } + + @Test fun largeBatchRoundTripsInPositionOrder() = runBlocking { + val books = (0 until 50).map { book(id = 1000 - it, position = it) } + dao.replaceAll(books) + val out = dao.observeAll().first() + assertEquals(50, out.size) + assertEquals((0 until 50).toList(), out.map { it.position }) + } +} diff --git a/app/src/test/java/com/pinakes/app/CatalogSyncWorkerTest.kt b/app/src/test/java/com/pinakes/app/CatalogSyncWorkerTest.kt new file mode 100644 index 0000000..b263831 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/CatalogSyncWorkerTest.kt @@ -0,0 +1,29 @@ +package com.pinakes.app + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.work.ListenableWorker +import androidx.work.testing.TestListenableWorkerBuilder +import com.pinakes.app.data.sync.CatalogSyncWorker +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The periodic catalog-sync worker (touched: WorkManager #11). Under a plain test + * Application (not PinakesApplication) the worker degrades to a successful no-op + * instead of crashing — so a missing/odd Application context never fails the chain. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class) +class CatalogSyncWorkerTest { + + @Test fun succeedsAsNoOpWhenContextIsNotPinakesApplication() = runBlocking { + val ctx = ApplicationProvider.getApplicationContext() + val worker = TestListenableWorkerBuilder(ctx).build() + assertEquals(ListenableWorker.Result.success(), worker.doWork()) + } +} diff --git a/app/src/test/java/com/pinakes/app/DateFormatMoreTest.kt b/app/src/test/java/com/pinakes/app/DateFormatMoreTest.kt new file mode 100644 index 0000000..7fd0667 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/DateFormatMoreTest.kt @@ -0,0 +1,28 @@ +package com.pinakes.app + +import com.pinakes.app.ui.common.DateFormat +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** More cases for the offset-aware date formatting (touched area: loans/profile dates). */ +class DateFormatMoreTest { + + @Test fun dateFromOffsetTimestampIsFormatted() { + val out = DateFormat.date("2026-06-12T23:30:00+02:00") + assertNotEquals("—", out) + assertTrue("expected year in '$out'", out.contains("2026")) + } + + @Test fun dateTimeUnparseableReturnsRawInput() { + assertEquals("garbage", DateFormat.dateTime("garbage")) + } + + @Test fun todayIsoParsesBackToALocalDate() { + // todayIso() must be a valid yyyy-MM-dd that date() can format (no em-dash, no echo). + val today = DateFormat.todayIso() + assertTrue(today.matches(Regex("""\d{4}-\d{2}-\d{2}"""))) + assertNotEquals("—", DateFormat.date(today)) + } +} diff --git a/app/src/test/java/com/pinakes/app/NetworkUrlMoreTest.kt b/app/src/test/java/com/pinakes/app/NetworkUrlMoreTest.kt new file mode 100644 index 0000000..96120ea --- /dev/null +++ b/app/src/test/java/com/pinakes/app/NetworkUrlMoreTest.kt @@ -0,0 +1,33 @@ +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 + +/** More cases for instance-URL derivation + transport policy (touched: NetworkModule). */ +class NetworkUrlMoreTest { + + @Test fun keepsExplicitPort() { + assertEquals( + "http://192.168.1.50:8080/api/v1/", + NetworkModule.deriveApiBaseUrl("http://192.168.1.50:8080"), + ) + } + + @Test fun originDropsApiV1AndTrailingSlash() { + assertEquals("https://lib.example.org", NetworkModule.deriveOrigin("https://lib.example.org/api/v1/")) + assertEquals("https://lib.example.org", NetworkModule.deriveOrigin("lib.example.org")) + } + + @Test fun httpAllowedForLoopbackAndEmulatorHostOnly() { + assertTrue(NetworkModule.isTransportAllowed("http://localhost:8081/api/v1/")) + assertTrue(NetworkModule.isTransportAllowed("http://127.0.0.1/api/v1/")) + assertTrue(NetworkModule.isTransportAllowed("http://10.0.2.2:8081/api/v1/")) // Android emulator host + } + + @Test fun plainHttpToAPublicHostIsRejected() { + assertFalse(NetworkModule.isTransportAllowed("http://lib.example.org/api/v1/")) + } +} diff --git a/app/src/test/java/com/pinakes/app/StatusMappingMoreTest.kt b/app/src/test/java/com/pinakes/app/StatusMappingMoreTest.kt new file mode 100644 index 0000000..23f6140 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/StatusMappingMoreTest.kt @@ -0,0 +1,56 @@ +package com.pinakes.app + +import com.pinakes.app.ui.common.StatusMapping +import com.pinakes.app.ui.components.AvailabilityStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Colour-bucket / priority-group mapping of backend loan & reservation states. */ +class StatusMappingMoreTest { + + @Test fun loanMapsKnownStatesToColourBuckets() { + assertEquals(AvailabilityStatus.Available, StatusMapping.loan("in_corso").first) + assertEquals(AvailabilityStatus.Overdue, StatusMapping.loan("in_ritardo").first) + assertEquals(AvailabilityStatus.ReservedReady, StatusMapping.loan("da_ritirare").first) + assertEquals(AvailabilityStatus.DueSoon, StatusMapping.loan("pendente").first) + } + + @Test fun loanUnknownStateFallsBackToTitleCasedRaw() { + val label = StatusMapping.loan("qualche_stato_strano").second + assertNull(label.resId) + assertEquals("Qualche stato strano", label.fallback) + } + + @Test fun reservationMapsEnglishAndItalianSpellings() { + assertEquals(AvailabilityStatus.Available, StatusMapping.reservation("attiva").first) + assertEquals(AvailabilityStatus.Available, StatusMapping.reservation("active").first) + assertEquals(AvailabilityStatus.DueSoon, StatusMapping.reservation("pending").first) + assertEquals(AvailabilityStatus.ReservedReady, StatusMapping.reservation("ready").first) + } + + @Test fun reservationUnknownFallsBackToTitleCasedRaw() { + val label = StatusMapping.reservation("foo_bar").second + assertNull(label.resId) + assertEquals("Foo bar", label.fallback) + } + + @Test fun loanGroupOrdersUrgentStatesFirst() { + assertEquals(StatusMapping.LoanGroup.Overdue, StatusMapping.loanGroup("in_ritardo")) + assertEquals(StatusMapping.LoanGroup.Overdue, StatusMapping.loanGroup("perso")) + assertEquals(StatusMapping.LoanGroup.OnLoan, StatusMapping.loanGroup("in_corso")) + assertEquals(StatusMapping.LoanGroup.ReadyScheduled, StatusMapping.loanGroup("prenotato")) + assertEquals(StatusMapping.LoanGroup.Pending, StatusMapping.loanGroup("pendente")) + } + + @Test fun loanGroupUnknownDefaultsToPending() { + assertEquals(StatusMapping.LoanGroup.Pending, StatusMapping.loanGroup("whatever")) + } + + @Test fun loanGroupOrderIsOverdueBeforeOnLoanBeforeReadyBeforePending() { + assertTrue(StatusMapping.LoanGroup.Overdue.order < StatusMapping.LoanGroup.OnLoan.order) + assertTrue(StatusMapping.LoanGroup.OnLoan.order < StatusMapping.LoanGroup.ReadyScheduled.order) + assertTrue(StatusMapping.LoanGroup.ReadyScheduled.order < StatusMapping.LoanGroup.Pending.order) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 24c31da..7b6d236 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,6 +54,7 @@ androidx-room-compiler = { group = "androidx.room", name = "room-compiler", vers androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } +androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" } 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" }