Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -315,4 +315,5 @@ dependencies {
testImplementation(libs.robolectric)
testImplementation(libs.androidx.room.testing)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.work.testing)
}
30 changes: 30 additions & 0 deletions app/src/test/java/com/pinakes/app/BookSummaryEdgeTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
49 changes: 49 additions & 0 deletions app/src/test/java/com/pinakes/app/CachedBookMapperEdgeTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
75 changes: 75 additions & 0 deletions app/src/test/java/com/pinakes/app/CatalogDaoEdgeTest.kt
Original file line number Diff line number Diff line change
@@ -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<CachedBook>(), 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 })
}
}
29 changes: 29 additions & 0 deletions app/src/test/java/com/pinakes/app/CatalogSyncWorkerTest.kt
Original file line number Diff line number Diff line change
@@ -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<Context>()
val worker = TestListenableWorkerBuilder<CatalogSyncWorker>(ctx).build()
assertEquals(ListenableWorker.Result.success(), worker.doWork())
}
}
28 changes: 28 additions & 0 deletions app/src/test/java/com/pinakes/app/DateFormatMoreTest.kt
Original file line number Diff line number Diff line change
@@ -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))
}
}
33 changes: 33 additions & 0 deletions app/src/test/java/com/pinakes/app/NetworkUrlMoreTest.kt
Original file line number Diff line number Diff line change
@@ -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/"))
}
}
56 changes: 56 additions & 0 deletions app/src/test/java/com/pinakes/app/StatusMappingMoreTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
Loading