diff --git a/_contract/openapi.json b/_contract/openapi.json index e4c7764..f17de8d 100644 --- a/_contract/openapi.json +++ b/_contract/openapi.json @@ -839,7 +839,7 @@ "type": "string", "format": "date", "nullable": true, - "deprecated": true + "description": "Optional return date (Mobile API 1.8.2). Omitted, the library's loan_duration_days is added to the start. Send the day before the first fully-booked day of GET /catalog/books/{id}/availability when the default would cross it; must not precede the start nor exceed max_loan_duration_days (422 max_duration_exceeded)." } } }, diff --git a/app/src/androidTest/java/com/pinakes/app/CalendarLayoutTest.kt b/app/src/androidTest/java/com/pinakes/app/CalendarLayoutTest.kt index 189db7b..c579167 100644 --- a/app/src/androidTest/java/com/pinakes/app/CalendarLayoutTest.kt +++ b/app/src/androidTest/java/com/pinakes/app/CalendarLayoutTest.kt @@ -54,7 +54,7 @@ class CalendarLayoutTest { } @Test fun loanDialogMonthFitsThePhoneScreenAndNavigates() { - compose.setContent { PinakesTheme { LoanRequestSheet(loading = false, calendar = calendar(), fallback = false, submitting = false, onDismiss = {}, onConfirm = {}) } } + compose.setContent { PinakesTheme { LoanRequestSheet(loading = false, calendar = calendar(), fallback = false, submitting = false, onDismiss = {}, onConfirm = { _, _ -> }) } } val metrics = context.resources.displayMetrics val screen = Rect(0f, 0f, metrics.widthPixels.toFloat(), metrics.heightPixels.toFloat()) val dialog = compose.onNodeWithTag("loan-dialog").fetchSemanticsNode().boundsInRoot @@ -70,7 +70,7 @@ class CalendarLayoutTest { } @Test fun fallbackDialogUsesTheSameFittingGrid() { - compose.setContent { PinakesTheme { LoanRequestSheet(loading = false, calendar = null, fallback = true, submitting = false, onDismiss = {}, onConfirm = {}) } } + compose.setContent { PinakesTheme { LoanRequestSheet(loading = false, calendar = null, fallback = true, submitting = false, onDismiss = {}, onConfirm = { _, _ -> }) } } val dialog = compose.onNodeWithTag("loan-dialog").fetchSemanticsNode().boundsInRoot assertMonthInside(YearMonth.now(), dialog) } diff --git a/app/src/main/java/com/pinakes/app/data/model/Models.kt b/app/src/main/java/com/pinakes/app/data/model/Models.kt index 1a6ef06..eddfc99 100644 --- a/app/src/main/java/com/pinakes/app/data/model/Models.kt +++ b/app/src/main/java/com/pinakes/app/data/model/Models.kt @@ -41,6 +41,12 @@ data class Meta( val replayed: Boolean? = null, // 409 `merge_error` on an author/publisher merge: ids that no longer exist (already merged). val missing: List? = null, + // 409 `not_available` on POST /reservations (Mobile API 1.8.2): when the start day is free + // but a scheduled loan or reservation begins inside the requested window, the longest loan + // still possible, so the app can propose that return date. Absent otherwise and on older servers. + @SerialName("max_end_date") val maxEndDate: String? = null, + @SerialName("max_duration_days") val maxDurationDays: Int? = null, + @SerialName("first_conflict_date") val firstConflictDate: String? = null, ) @Serializable @@ -516,6 +522,8 @@ data class AvailabilityCalendar( // Days POST /reservations adds to the chosen start to set the return date (Mobile API // 1.8.1). Null on older servers: the dialog then names the standard period without a date. @SerialName("loan_duration_days") val loanDurationDays: Int? = null, + // Longest loan POST /reservations accepts through end_date (Mobile API 1.8.2); null on older servers. + @SerialName("max_loan_duration_days") val maxLoanDurationDays: Int? = null, ) @Serializable @@ -535,7 +543,9 @@ data class ReservationRequest( // request flow sends. @SerialName("desired_date") val desiredDate: String? = null, @SerialName("start_date") val startDate: String? = null, // legacy/compat, yyyy-MM-dd - @SerialName("end_date") val endDate: String? = null, // legacy/compat, yyyy-MM-dd + // Optional return date (Mobile API 1.8.2): the day before a fully-booked day when the + // library's duration would cross it (see LoanWindow). Omitted, the server adds the duration. + @SerialName("end_date") val endDate: String? = null, // yyyy-MM-dd ) /** Authoritative result of POST /reservations (loan request or FIFO reservation). */ diff --git a/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt index f433437..27efa62 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt @@ -63,17 +63,19 @@ class LibraryRepository( /** * Request a loan/reservation for [bookId] starting on [desiredDate] (yyyy-MM-dd). The * backend sets the return date from the library's configured loan duration - * (loan_duration_days), matching the website behaviour. + * (loan_duration_days), matching the website behaviour, unless [endDate] names an earlier + * one: the day before a fully-booked day the default would cross (Mobile API 1.8.2). */ suspend fun reserve( bookId: Int, desiredDate: String? = null, + endDate: String? = null, ): ApiResult { val api = network.api() // The request lands in pending loans or in reservations; the caller refreshes the // offline copy afterwards (refreshCache) so the confirmation is not held back by // two more round trips. - return apiCall { api.createReservation(ReservationRequest(bookId = bookId, desiredDate = desiredDate)) } + return apiCall { api.createReservation(ReservationRequest(bookId = bookId, desiredDate = desiredDate, endDate = endDate)) } } suspend fun cancelReservation(reservationId: Int): ApiResult { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt index 9218c57..d2d9986 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt @@ -221,7 +221,7 @@ fun BookDetailScreen( fallback = state.availabilityFallback, submitting = state.reserveBusy, onDismiss = vm::dismissLoanSheet, - onConfirm = vm::reserve, + onConfirm = { start, end -> vm.reserve(start, end) }, ) } } @@ -238,7 +238,8 @@ internal fun LoanRequestSheet( fallback: Boolean, submitting: Boolean, onDismiss: () -> Unit, - onConfirm: (isoDate: String) -> Unit, + /** The chosen start and, when a fully-booked day shortens the default duration, the return date. */ + onConfirm: (isoDate: String, endIso: String?) -> Unit, /** Passed to [LoanCalendar]; null reads the device clock. */ today: LocalDate? = null, ) { @@ -269,7 +270,8 @@ internal fun LoanRequestSheetContent( fallback: Boolean, submitting: Boolean, onDismiss: () -> Unit, - onConfirm: (isoDate: String) -> Unit, + /** The chosen start and, when a fully-booked day shortens the default duration, the return date. */ + onConfirm: (isoDate: String, endIso: String?) -> Unit, /** Passed to [LoanCalendar]; null reads the device clock. */ today: LocalDate? = null, ) { @@ -277,6 +279,11 @@ internal fun LoanRequestSheetContent( var selectedIso by remember(calendar) { mutableStateOf(calendar?.earliestAvailable?.takeIf { it.isNotBlank() }) } + // A fully-booked day the reader tapped: explained under the calendar until the next pick. + var blockedIso by remember(calendar) { mutableStateOf(null) } + // The loan still possible from the chosen start: the default duration, or shorter when a + // scheduled loan or reservation begins inside it (then the request carries end_date). + val window = selectedIso?.let { loanWindowFrom(it, calendar, calendar?.loanDurationDays) } Surface( shape = RoundedCornerShape(20.dp), @@ -322,8 +329,9 @@ internal fun LoanRequestSheetContent( LoanCalendar( calendar = calendar, selected = selectedIso, - onSelect = { selectedIso = it }, + onSelect = { selectedIso = it; blockedIso = null }, today = today, + onBlockedTap = { blockedIso = it }, ) } @@ -345,18 +353,47 @@ internal fun LoanRequestSheetContent( } } - // The return date uses the server's configured duration (#51). + // The return date uses the server's configured duration (#51), stopped the day + // before a fully-booked day when one falls inside it. val hint = selectedIso?.let { start -> - calendar?.loanDurationDays?.let { loanReturnDate(start, it) }?.let { end -> - stringResource(R.string.book_loan_period_range, formatDisplayDate(start), formatDisplayDate(end)) + window?.let { w -> + stringResource(R.string.book_loan_period_range, formatDisplayDate(start), formatDisplayDate(w.endDate)) } ?: stringResource(R.string.book_loan_period_hint, formatDisplayDate(start)) } ?: stringResource(R.string.book_loan_pick_prompt) Text( text = hint, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = Spacing.md), + modifier = Modifier.padding(top = Spacing.md, bottom = if (window?.capped == true || blockedIso != null) Spacing.xs else Spacing.md), ) + // Why the loan is shorter than the library's standard duration, and why a tapped + // red day cannot be chosen: the same explanation the website gives. + val limitText = window?.takeIf { it.capped }?.let { w -> + if (!w.possible) { + // The next day is blocked: no loan of at least one day from this start. + stringResource(R.string.book_loan_no_window, formatDisplayDate(w.firstBlocked ?: w.endDate)) + } else if (w.maxDays == 1) { + // The string generator has no plurals: a singular variant instead of "1 days". + stringResource(R.string.book_loan_limit_note_one, formatDisplayDate(w.endDate), formatDisplayDate(w.firstBlocked ?: w.endDate)) + } else { + stringResource( + R.string.book_loan_limit_note, + w.maxDays, + formatDisplayDate(w.endDate), + formatDisplayDate(w.firstBlocked ?: w.endDate), + ) + } + } + val blockedText = blockedIso?.let { stringResource(R.string.book_loan_blocked_day, formatDisplayDate(it)) } + val explanation = listOfNotNull(blockedText, limitText).joinToString(" ") + if (explanation.isNotEmpty()) { + Text( + text = explanation, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.padding(bottom = Spacing.md).testTag("loan-limit"), + ) + } Column( Modifier.padding(bottom = Spacing.lg), @@ -364,8 +401,8 @@ internal fun LoanRequestSheetContent( ) { PrimaryButton( label = stringResource(R.string.book_loan_confirm), - onClick = { selectedIso?.let(onConfirm) }, - enabled = selectedIso != null, + onClick = { selectedIso?.let { start -> onConfirm(start, window?.takeIf { it.requiresEndDate }?.endDate) } }, + enabled = selectedIso != null && window?.possible != false, loading = submitting, modifier = Modifier.fillMaxWidth().testTag("loan-confirm"), ) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt index b758c9d..c3e43d0 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt @@ -179,14 +179,16 @@ class BookDetailViewModel @Inject constructor( /** * Submit a loan/reservation request for [desiredDate] (yyyy-MM-dd, today-or-future). The - * backend sets the return from the library's configured loan duration. On success we surface - * the chosen date so the UI can confirm exactly what was requested. + * backend sets the return from the library's configured loan duration, unless [endDate] + * asks for an earlier one (the day before a fully-booked day the default would cross, see + * LoanWindow). On success we surface the chosen date so the UI can confirm exactly what + * was requested. */ - fun reserve(desiredDate: String) { + fun reserve(desiredDate: String, endDate: String? = null) { if (_state.value.reserveBusy) return _state.update { it.copy(reserveBusy = true) } viewModelScope.launch { - when (val res = library.reserve(bookId, desiredDate)) { + when (val res = library.reserve(bookId, desiredDate, endDate)) { is ApiResult.Success -> { _state.update { it.copy( @@ -207,6 +209,9 @@ class BookDetailViewModel @Inject constructor( is ApiResult.Failure -> { // Close the calendar dialog so the error is visible: the snackbar // lives at the screen Scaffold level, behind the open DatePickerDialog. + // A `not_available` refusal carries the server's own explanation (the + // longest loan still possible, and from which day the book is booked): + // that message, not the generic conflict text, is what the reader sees. when { res.code == ErrorCodes.CONFLICT -> _state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = null, snackbarRes = R.string.snackbar_request_conflict) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt index 7bc1e0a..ef79bfb 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt @@ -87,12 +87,10 @@ private fun ruled(container: Color, onContainer: Color) = DayTint(container, onContainer, mix(onContainer, container, .22f)) /** Index per-day data by ISO date for O(1) lookup, and the set of unavailable dates. */ -private class CalendarModel(calendar: AvailabilityCalendar) { +private class CalendarModel(private val source: AvailabilityCalendar) { private val byDate: Map = - calendar.days.associate { it.date to it.state } // date -> state - private val available: Map = - calendar.days.associate { it.date to it.available } - private val unavailable: Set = calendar.unavailableDates.toSet() + source.days.associate { it.date to it.state } // date -> state + private val unavailable: Set = source.unavailableDates.toSet() private val knownDates: Set = byDate.keys /** True only when we have fetched data covering this day at all. */ @@ -101,11 +99,11 @@ private class CalendarModel(calendar: AvailabilityCalendar) { fun kind(date: LocalDate, today: LocalDate): DayKind { val iso = date.toString() if (date.isBefore(today)) return DayKind.PastOrUnknown - if (unavailable.contains(iso)) return DayKind.Full + // Same predicate as loanWindowFrom(): a red day is a day that caps the loan. + if (source.isFullyBooked(iso)) return DayKind.Full return when (byDate[iso]) { - "free" -> if ((available[iso] ?: 1) > 0) DayKind.Free else DayKind.Full + "free" -> DayKind.Free "partial" -> DayKind.Partial - "full" -> DayKind.Full else -> DayKind.PastOrUnknown // beyond the fetched window → unknown/disabled } } @@ -136,6 +134,8 @@ fun LoanCalendar( anyFutureDay: Boolean = false, /** The day the grid treats as today; null reads the device clock once. Tests pin it. */ today: LocalDate? = null, + /** A fully-booked future day cannot be picked; tapping it calls this so the sheet can say why. */ + onBlockedTap: ((isoDate: String) -> Unit)? = null, ) { val dark = LocalPinakesColors.current.darkMode val deviceToday = remember { LocalDate.now() } @@ -167,6 +167,7 @@ fun LoanCalendar( dark = dark, onSelect = onSelect, anyFutureDay = anyFutureDay, + onBlockedTap = onBlockedTap, ) if (!anyFutureDay) { Spacer(Modifier.height(Spacing.md)) @@ -234,6 +235,7 @@ private fun MonthGrid( dark: Boolean, onSelect: (String) -> Unit, anyFutureDay: Boolean = false, + onBlockedTap: ((String) -> Unit)? = null, ) { val firstOfMonth = month.atDay(1) // Mon=1 .. Sun=7 → leading blanks before day 1. @@ -262,6 +264,7 @@ private fun MonthGrid( dark = dark, onSelect = onSelect, anyFutureDay = anyFutureDay, + onBlockedTap = onBlockedTap, ) } } @@ -280,6 +283,7 @@ private fun DayCell( dark: Boolean, onSelect: (String) -> Unit, anyFutureDay: Boolean = false, + onBlockedTap: ((String) -> Unit)? = null, ) { val iso = date.toString() val kind = model.kind(date, today) @@ -310,7 +314,12 @@ private fun DayCell( if (rule != Color.Transparent) Modifier.border(1.dp, rule, CircleShape) else Modifier ) .then( - if (selectable) Modifier.clickable { onSelect(iso) } else Modifier + when { + selectable -> Modifier.clickable { onSelect(iso) } + // Not pickable, but a tap must still explain why (every copy is booked). + onBlockedTap != null && kind == DayKind.Full -> Modifier.clickable { onBlockedTap(iso) } + else -> Modifier + } ) Box(cellModifier, contentAlignment = Alignment.Center) { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanWindow.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanWindow.kt new file mode 100644 index 0000000..ed4331d --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanWindow.kt @@ -0,0 +1,71 @@ +package com.pinakes.app.ui.screens.detail + +import com.pinakes.app.data.model.AvailabilityCalendar +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +/** + * The loan the reader can still ask for from a start day, read off the availability calendar. + * + * The server adds the library's `loan_duration_days` to the chosen start to set the return + * date. When a scheduled loan or a reservation on every copy (a `full` day) begins before that + * default return date, the server refuses the whole window, so the request must stop the day + * before it instead: that is [endDate], sent as `end_date` of POST /reservations (Mobile API + * 1.8.2), and [capped] says the default was shortened, which the sheet explains under the + * calendar with [maxDays] and [firstBlocked]. Same rule as the website's calendar. + */ +data class LoanWindow( + /** The return date to request: start + duration, or the day before [firstBlocked]. */ + val endDate: String, + /** The first fully-booked day inside the default duration, or null. */ + val firstBlocked: String?, + /** Days from the start to [endDate]. */ + val maxDays: Int, + /** True when [firstBlocked] shortened the default duration. */ + val capped: Boolean, + /** Send the displayed date when either a commitment or the configured maximum shortened it. */ + val requiresEndDate: Boolean = capped, +) { + /** False when the very next day is blocked: no loan of at least one day exists from this start. */ + val possible: Boolean get() = maxDays >= 1 +} + +/** + * A day the grid paints red and refuses to pick: listed in `unavailable_dates`, marked `full`, + * or labelled `free` with no free copy (a stale label the server can send). One predicate for + * the calendar and the window, so what the reader sees as blocked is what caps the loan. + */ +internal fun AvailabilityCalendar.isFullyBooked(isoDate: String): Boolean { + if (unavailableDates.contains(isoDate)) return true + val day = days.firstOrNull { it.date == isoDate } ?: return false + return day.state == "full" || (day.state == "free" && day.available <= 0) +} + +/** + * The window from [startIso] (yyyy-MM-dd), or null when the duration is unknown (an older + * server omits `loan_duration_days`) or the start is not a date: then the server's default + * applies and nothing can be said about it. + */ +internal fun loanWindowFrom(startIso: String, calendar: AvailabilityCalendar?, loanDurationDays: Int?): LoanWindow? { + if (loanDurationDays == null || loanDurationDays < 1) return null + val start = runCatching { LocalDate.parse(startIso) }.getOrNull() ?: return null + val durationDays = calendar?.maxLoanDurationDays?.takeIf { it > 0 } + ?.let { minOf(loanDurationDays, it) } ?: loanDurationDays + var firstBlocked: String? = null + for (offset in 1..durationDays) { + val day = start.plusDays(offset.toLong()).toString() + if (calendar?.isFullyBooked(day) == true) { + firstBlocked = day + break + } + } + val defaultEnd = start.plusDays(durationDays.toLong()) + val end = firstBlocked?.let { LocalDate.parse(it).minusDays(1) } ?: defaultEnd + return LoanWindow( + endDate = end.toString(), + firstBlocked = firstBlocked, + maxDays = ChronoUnit.DAYS.between(start, end).toInt(), + capped = firstBlocked != null, + requiresEndDate = end.isBefore(start.plusDays(loanDurationDays.toLong())), + ) +} diff --git a/app/src/test/java/com/pinakes/app/LoanCalendarRegressionTest.kt b/app/src/test/java/com/pinakes/app/LoanCalendarRegressionTest.kt index 1758f1b..14f6d41 100644 --- a/app/src/test/java/com/pinakes/app/LoanCalendarRegressionTest.kt +++ b/app/src/test/java/com/pinakes/app/LoanCalendarRegressionTest.kt @@ -138,7 +138,7 @@ class LoanCalendarRegressionTest { @Test fun loadingLeavesCancelAvailableAndPreventsAnEmptyRequest() { compose.setContent { PinakesTheme { - LoanRequestSheetContent(true, null, false, false, {}, { picked = it }) + LoanRequestSheetContent(true, null, false, false, {}, { start, _ -> picked = start }) } } compose.onNodeWithTag("loan-confirm").assertIsNotEnabled() compose.onNodeWithTag("loan-cancel").assertIsEnabled() @@ -149,7 +149,7 @@ class LoanCalendarRegressionTest { @Test fun availabilityArrivingAfterLoadingPreselectsTheServerSuggestedDay() { var data by mutableStateOf(null) compose.setContent { PinakesTheme { - LoanRequestSheetContent(data == null, data, false, false, {}, { picked = it }) + LoanRequestSheetContent(data == null, data, false, false, {}, { start, _ -> picked = start }) } } compose.runOnIdle { data = calendar() } compose.onNodeWithTag("loan-confirm").performScrollTo().assertIsEnabled().performClick() @@ -161,7 +161,7 @@ class LoanCalendarRegressionTest { val data = calendar().copy(days = listOf(AvailabilityDay(target.toString(), available = 1), AvailabilityDay(next.toString(), available = 1))) compose.setContent { PinakesTheme { - LoanRequestSheetContent(false, data, false, false, {}, { picked = it }) + LoanRequestSheetContent(false, data, false, false, {}, { start, _ -> picked = start }) } } day(next).performScrollTo().performClick() compose.onNodeWithTag("loan-confirm").performScrollTo().performClick() @@ -171,7 +171,7 @@ class LoanCalendarRegressionTest { @Test fun fallbackSelectionIsConfirmedAsAnIsoDate() { val today = LocalDate.now() compose.setContent { PinakesTheme { - LoanRequestSheetContent(false, null, true, false, {}, { picked = it }) + LoanRequestSheetContent(false, null, true, false, {}, { start, _ -> picked = start }) } } compose.onNodeWithTag("loan-confirm").assertIsNotEnabled() day(today).performScrollTo().performClick() @@ -183,7 +183,7 @@ class LoanCalendarRegressionTest { var dismissed = false compose.setContent { PinakesTheme { Box(Modifier.width(320.dp).height(240.dp)) { - LoanRequestSheetContent(false, calendar(), false, false, { dismissed = true }, {}) + LoanRequestSheetContent(false, calendar(), false, false, { dismissed = true }, { _, _ -> }) } } } compose.onNodeWithTag("loan-cancel").performScrollTo().assertIsDisplayed() diff --git a/app/src/test/java/com/pinakes/app/LoanRequestReturnDateTest.kt b/app/src/test/java/com/pinakes/app/LoanRequestReturnDateTest.kt index 45ab92d..9524c8e 100644 --- a/app/src/test/java/com/pinakes/app/LoanRequestReturnDateTest.kt +++ b/app/src/test/java/com/pinakes/app/LoanRequestReturnDateTest.kt @@ -48,7 +48,7 @@ class LoanRequestReturnDateTest { private fun show(calendar: AvailabilityCalendar) { compose.setContent { PinakesTheme { LoanRequestSheet(loading = false, calendar = calendar, fallback = false, submitting = false, - onDismiss = {}, onConfirm = {}) + onDismiss = {}, onConfirm = { _, _ -> }) } } } diff --git a/app/src/test/java/com/pinakes/app/LoanRequestWindowTest.kt b/app/src/test/java/com/pinakes/app/LoanRequestWindowTest.kt new file mode 100644 index 0000000..a3cf66d --- /dev/null +++ b/app/src/test/java/com/pinakes/app/LoanRequestWindowTest.kt @@ -0,0 +1,131 @@ +package com.pinakes.app + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.core.app.ApplicationProvider +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.data.model.AvailabilityDay +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.screens.detail.LoanRequestSheetContent +import com.pinakes.app.ui.theme.PinakesTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The loan-request sheet stops the return date the day before a fully-booked day inside the + * library's loan duration, says so, sends that date as end_date, and explains a tapped red day. + * Same rule as the website calendar and the server's LoanWindow (Mobile API 1.8.2). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class LoanRequestWindowTest { + private val compose = createComposeRule() + private val context get() = ApplicationProvider.getApplicationContext() + @get:Rule val rules: RuleChain = RuleChain.outerRule(object : TestWatcher() { + override fun starting(description: Description) { + val app = ApplicationProvider.getApplicationContext() + shadowOf(app.packageManager).addActivityIfNotPresent(ComponentName(app.packageName, ComponentActivity::class.java.name)) + } + }).around(compose) + + private var pickedStart: String? = null + private var pickedEnd: String? = null + + /** A single-copy title, free from 2030-03-04, fully booked on [full]. */ + private fun calendar(vararg full: String, duration: Int = 14) = AvailabilityCalendar( + totalCopies = 1, + earliestAvailable = "2030-03-04", + loanDurationDays = duration, + days = (1..40).map { i -> + val date = java.time.LocalDate.parse("2030-03-04").plusDays(i.toLong() - 1).toString() + if (date in full) AvailabilityDay(date = date, available = 0, loaned = 1, state = "full") + else AvailabilityDay(date = date, available = 1, state = "free") + }, + ) + + private fun show(calendar: AvailabilityCalendar) { + compose.setContent { PinakesTheme { + LoanRequestSheetContent(false, calendar, false, false, {}, { start, end -> pickedStart = start; pickedEnd = end }) + } } + } + + @Test fun aScheduledLoanInsideTheDurationShortensTheReturnDateAndSaysSo() { + show(calendar("2030-03-09")) + compose.onNodeWithText(context.getString(R.string.book_loan_period_range, DateFormat.date("2030-03-04"), DateFormat.date("2030-03-08"))) + .performScrollTo().assertIsDisplayed() + compose.onNodeWithTag("loan-limit").performScrollTo().assertIsDisplayed() + compose.onNodeWithText(context.getString(R.string.book_loan_limit_note, 4, DateFormat.date("2030-03-08"), DateFormat.date("2030-03-09"))) + .assertIsDisplayed() + } + + @Test fun confirmingACappedRequestSendsTheShortenedReturnDate() { + show(calendar("2030-03-09")) + compose.onNodeWithTag("loan-confirm").performScrollTo().assertIsEnabled().performClick() + compose.runOnIdle { + assertEquals("2030-03-04", pickedStart) + assertEquals("2030-03-08", pickedEnd) + } + } + + @Test fun withNoConflictTheServerKeepsItsDefaultAndNothingIsExplained() { + show(calendar("2030-04-20")) + compose.onNodeWithText(context.getString(R.string.book_loan_period_range, DateFormat.date("2030-03-04"), DateFormat.date("2030-03-18"))) + .performScrollTo().assertIsDisplayed() + compose.onNodeWithTag("loan-limit").assertDoesNotExist() + compose.onNodeWithTag("loan-confirm").performScrollTo().performClick() + compose.runOnIdle { + assertEquals("2030-03-04", pickedStart) + assertNull(pickedEnd) + } + } + + @Test fun tappingAFullyBookedDayExplainsWhyItCannotBePicked() { + show(calendar("2030-03-09")) + compose.onNodeWithTag("calendar-day-2030-03-09", useUnmergedTree = true).performScrollTo().performClick() + val expected = context.getString(R.string.book_loan_blocked_day, DateFormat.date("2030-03-09")) + " " + + context.getString(R.string.book_loan_limit_note, 4, DateFormat.date("2030-03-08"), DateFormat.date("2030-03-09")) + compose.onNodeWithText(expected).performScrollTo().assertIsDisplayed() + // The pick is unchanged: the blocked day never becomes the start. + compose.onNodeWithTag("loan-confirm").performScrollTo().performClick() + compose.runOnIdle { assertEquals("2030-03-04", pickedStart) } + } + + @Test fun aCommitmentOnTheVeryNextDayLeavesNoLoanAndDisablesTheRequest() { + show(calendar("2030-03-05")) + compose.onNodeWithText(context.getString(R.string.book_loan_no_window, DateFormat.date("2030-03-05"))) + .performScrollTo().assertIsDisplayed() + compose.onNodeWithTag("loan-confirm").performScrollTo().assertIsNotEnabled() + } + + @Test fun theMaximumDurationIsShownAndSentWithoutABookingExplanation() { + show(calendar("2030-03-14", duration = 30).copy(maxLoanDurationDays = 7)) + compose.onNodeWithText(context.getString(R.string.book_loan_period_range, DateFormat.date("2030-03-04"), DateFormat.date("2030-03-11"))) + .performScrollTo().assertIsDisplayed() + compose.onNodeWithTag("loan-limit").assertDoesNotExist() + compose.onNodeWithTag("loan-confirm").performScrollTo().performClick() + compose.runOnIdle { + assertEquals("2030-03-04", pickedStart) + assertEquals("2030-03-11", pickedEnd) + } + } +} diff --git a/app/src/test/java/com/pinakes/app/LoanWindowTest.kt b/app/src/test/java/com/pinakes/app/LoanWindowTest.kt new file mode 100644 index 0000000..5e9337e --- /dev/null +++ b/app/src/test/java/com/pinakes/app/LoanWindowTest.kt @@ -0,0 +1,109 @@ +package com.pinakes.app + +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.data.model.AvailabilityDay +import com.pinakes.app.ui.screens.detail.isFullyBooked +import com.pinakes.app.ui.screens.detail.loanWindowFrom +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The loan-request sheet stops the return date the day before a fully-booked day inside the + * library's loan duration, and says so; otherwise it proposes start + duration. Mirrors the + * website calendar and the server's LoanWindow (Mobile API 1.8.2). + */ +class LoanWindowTest { + private fun calendar(vararg full: String, partial: List = emptyList()) = AvailabilityCalendar( + totalCopies = 1, + days = (full.map { AvailabilityDay(date = it, available = 0, state = "full") } + + partial.map { AvailabilityDay(date = it, available = 1, state = "partial") }), + ) + + @Test fun aScheduledLoanInsideTheDurationCapsTheReturnDate() { + val window = loanWindowFrom("2030-03-04", calendar("2030-03-09", "2030-03-10"), 30) + assertEquals("2030-03-08", window?.endDate) + assertEquals("2030-03-09", window?.firstBlocked) + assertEquals(4, window?.maxDays) + assertTrue(window!!.capped) + } + + @Test fun noConflictMeansStartPlusTheDuration() { + val window = loanWindowFrom("2030-03-04", calendar("2030-05-01"), 14) + assertEquals("2030-03-18", window?.endDate) + assertNull(window?.firstBlocked) + assertEquals(14, window?.maxDays) + assertFalse(window!!.capped) + } + + @Test fun aConflictBeyondTheDurationDoesNotCap() { + val window = loanWindowFrom("2030-03-04", calendar("2030-03-19"), 14) + assertEquals("2030-03-18", window?.endDate) + assertFalse(window!!.capped) + } + + @Test fun thePickedDayItselfIsNeverTheConflict() { + // The chosen start is free by construction; only the days after it are searched. + val window = loanWindowFrom("2030-03-04", calendar("2030-03-04", "2030-03-05"), 14) + assertEquals("2030-03-05", window?.firstBlocked) + assertEquals("2030-03-04", window?.endDate) + assertEquals(0, window?.maxDays) + // A window shorter than a day is no loan: the sheet says so and disables the request. + assertFalse(window!!.possible) + assertTrue(loanWindowFrom("2030-03-04", calendar("2030-03-06"), 14)!!.possible) + } + + @Test fun aFreeLabelWithNoFreeCopyIsBlockedLikeTheCalendarPaintsIt() { + // CalendarModel paints state "free" with available 0 red and unpickable: the window agrees. + val stale = AvailabilityCalendar(totalCopies = 1, days = listOf(AvailabilityDay(date = "2030-03-07", available = 0, state = "free"))) + val window = loanWindowFrom("2030-03-04", stale, 14) + assertEquals("2030-03-06", window?.endDate) + assertTrue(window!!.capped) + assertTrue(stale.isFullyBooked("2030-03-07")) + assertFalse(stale.isFullyBooked("2030-03-06")) + } + + @Test fun aPartialDayStillHasAFreeCopy() { + val window = loanWindowFrom("2030-03-04", calendar(partial = listOf("2030-03-06")), 14) + assertFalse(window!!.capped) + } + + @Test fun unavailableDatesCountAsBlockedEvenWithoutADayRow() { + val window = loanWindowFrom("2030-03-04", AvailabilityCalendar(totalCopies = 1, unavailableDates = listOf("2030-03-07")), 14) + assertEquals("2030-03-06", window?.endDate) + assertTrue(window!!.capped) + } + + @Test fun unknownDurationOrBadStartGivesNoWindow() { + assertNull(loanWindowFrom("2030-03-04", calendar("2030-03-09"), null)) + assertNull(loanWindowFrom("2030-03-04", calendar("2030-03-09"), 0)) + assertNull(loanWindowFrom("not a date", calendar("2030-03-09"), 14)) + assertNull(loanWindowFrom("2030-03-04", null, null)) + } + + @Test fun theConfiguredMaximumWinsBeforeALaterCommitment() { + val calendar = calendar("2030-03-14").copy(maxLoanDurationDays = 7) + val window = loanWindowFrom("2030-03-04", calendar, 30)!! + assertEquals("2030-03-11", window.endDate) + assertEquals(7, window.maxDays) + assertNull(window.firstBlocked) + assertFalse(window.capped) // No booking explanation for a settings limit. + assertTrue(window.requiresEndDate) + } + + @Test fun aCommitmentInsideTheMaximumCanStillShortenTheLoan() { + val calendar = calendar("2030-03-09").copy(maxLoanDurationDays = 7) + val window = loanWindowFrom("2030-03-04", calendar, 30)!! + assertEquals("2030-03-08", window.endDate) + assertTrue(window.capped) + assertTrue(window.requiresEndDate) + } + + @Test fun anAlreadyBoundedDefaultNeedsNoExplicitEndDate() { + val window = loanWindowFrom("2030-03-04", calendar().copy(maxLoanDurationDays = 7), 7)!! + assertEquals("2030-03-11", window.endDate) + assertFalse(window.requiresEndDate) + } +} diff --git a/app/src/test/java/com/pinakes/app/ReservationRefusalEnvelopeTest.kt b/app/src/test/java/com/pinakes/app/ReservationRefusalEnvelopeTest.kt new file mode 100644 index 0000000..6d49a3d --- /dev/null +++ b/app/src/test/java/com/pinakes/app/ReservationRefusalEnvelopeTest.kt @@ -0,0 +1,62 @@ +package com.pinakes.app + +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.data.model.ReservationRequest +import com.pinakes.app.data.network.parseErrorBody +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * What the server says to the app on POST /reservations (Mobile API 1.8.2): a `not_available` + * refusal carries its own sentence as the message and the longest loan still possible in meta; + * the request carries `end_date` only when the sheet shortened the loan; the availability + * payload names both durations. + */ +class ReservationRefusalEnvelopeTest { + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = false } + + @Test fun aNotAvailableRefusalCarriesTheSentenceAndTheFigures() { + val body = """{"data":null,"meta":{"max_duration_days":4,"max_end_date":"2030-03-08","first_conflict_date":"2030-03-09"}, + "error":{"code":"not_available","message":"Durata massima del prestito: 4 giorni (restituzione entro il 08-03-2030). Dal 09-03-2030 il libro è già prenotato."}}""" + val failure = parseErrorBody(body, 409) + assertEquals("not_available", failure.code) + assertTrue(failure.fromEnvelope) + assertTrue(failure.message.contains("4 giorni")) + assertEquals("2030-03-08", failure.meta?.maxEndDate) + assertEquals(4, failure.meta?.maxDurationDays) + assertEquals("2030-03-09", failure.meta?.firstConflictDate) + } + + @Test fun aRefusalWithoutFiguresLeavesThemNull() { + val body = """{"data":null,"meta":{},"error":{"code":"not_available","message":"Il libro non è disponibile per il periodo richiesto."}}""" + val failure = parseErrorBody(body, 409) + assertEquals("not_available", failure.code) + assertNull(failure.meta?.maxEndDate) + assertNull(failure.meta?.maxDurationDays) + } + + @Test fun anOverLongReturnDateIsAValidationRefusalWithItsOwnCode() { + val failure = parseErrorBody("""{"data":null,"meta":{},"error":{"code":"max_duration_exceeded","message":"x"}}""", 422) + assertEquals("max_duration_exceeded", failure.code) + assertEquals(422, failure.httpStatus) + } + + @Test fun theRequestCarriesEndDateOnlyWhenTheSheetShortenedTheLoan() { + val plain = json.encodeToString(ReservationRequest(bookId = 7, desiredDate = "2030-03-04")) + assertFalse(plain.contains("end_date")) + val capped = json.encodeToString(ReservationRequest(bookId = 7, desiredDate = "2030-03-04", endDate = "2030-03-08")) + assertTrue(capped.contains("\"end_date\":\"2030-03-08\"")) + } + + @Test fun theAvailabilityPayloadNamesBothDurationsAndOlderServersOmitTheMaximum() { + val both = json.decodeFromString("""{"total_copies":1,"loan_duration_days":21,"max_loan_duration_days":90}""") + assertEquals(21, both.loanDurationDays) + assertEquals(90, both.maxLoanDurationDays) + assertNull(json.decodeFromString("""{"total_copies":1,"loan_duration_days":21}""").maxLoanDurationDays) + } +} diff --git a/i18n/de.json b/i18n/de.json index d6fbfdc..d8fd814 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -260,6 +260,10 @@ "book_loan_availability_loading": "Verfügbarkeit wird geprüft…", "book_loan_availability_error": "Verfügbarkeitskalender konnte nicht geladen werden; wählen Sie ein zukünftiges Datum.", "book_loan_pick_prompt": "Wählen Sie einen verfügbaren Tag", + "book_loan_limit_note": "Maximale Leihdauer: %1$d Tage (Rückgabe bis %2$s). Ab dem %3$s ist das Buch bereits vorgemerkt.", + "book_loan_limit_note_one": "Maximale Leihdauer: 1 Tag (Rückgabe bis %1$s). Ab dem %2$s ist das Buch bereits vorgemerkt.", + "book_loan_blocked_day": "Tag nicht verfügbar (%1$s): alle Exemplare sind bereits ausgeliehen oder vorgemerkt.", + "book_loan_no_window": "Ab diesem Startdatum ist keine Ausleihe möglich: ab dem %1$s ist das Buch bereits vorgemerkt. Wählen Sie ein anderes Datum.", "cd_prev_month": "Voriger Monat", "cd_next_month": "Nächster Monat", "snackbar_request_for_date": "Ausleihe angefragt für %1$s", diff --git a/i18n/en.json b/i18n/en.json index e22b8d7..33f4962 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -260,6 +260,10 @@ "book_loan_availability_loading": "Checking availability…", "book_loan_availability_error": "Couldn't load the availability calendar; pick any future date.", "book_loan_pick_prompt": "Select an available day", + "book_loan_limit_note": "Maximum loan length: %1$d days (return by %2$s). The book is already booked from %3$s.", + "book_loan_limit_note_one": "Maximum loan length: 1 day (return by %1$s). The book is already booked from %2$s.", + "book_loan_blocked_day": "Day not available (%1$s): every copy is already on loan or booked.", + "book_loan_no_window": "No loan is possible from this start date: the book is already booked from %1$s. Choose another date.", "cd_prev_month": "Previous month", "cd_next_month": "Next month", "snackbar_request_for_date": "Loan requested for %1$s", diff --git a/i18n/fr.json b/i18n/fr.json index 6ff4500..f51d7c5 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -260,6 +260,10 @@ "book_loan_availability_loading": "Vérification de la disponibilité…", "book_loan_availability_error": "Impossible de charger le calendrier de disponibilité; choisissez une date future.", "book_loan_pick_prompt": "Sélectionnez un jour disponible", + "book_loan_limit_note": "Durée maximale du prêt : %1$d jours (retour avant le %2$s). À partir du %3$s, le livre est déjà réservé.", + "book_loan_limit_note_one": "Durée maximale du prêt : 1 jour (retour avant le %1$s). À partir du %2$s, le livre est déjà réservé.", + "book_loan_blocked_day": "Jour non disponible (%1$s) : tous les exemplaires sont déjà empruntés ou réservés.", + "book_loan_no_window": "Aucun prêt n'est possible à partir de cette date : à partir du %1$s, le livre est déjà réservé. Choisissez une autre date.", "cd_prev_month": "Mois précédent", "cd_next_month": "Mois suivant", "snackbar_request_for_date": "Prêt demandé pour le %1$s", diff --git a/i18n/it.json b/i18n/it.json index 8cbd58a..580b07f 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -260,6 +260,10 @@ "book_loan_availability_loading": "Verifica disponibilità…", "book_loan_availability_error": "Impossibile caricare il calendario di disponibilità; scegli una data futura.", "book_loan_pick_prompt": "Seleziona un giorno disponibile", + "book_loan_limit_note": "Durata massima del prestito: %1$d giorni (restituzione entro il %2$s). Dal %3$s il libro è già prenotato.", + "book_loan_limit_note_one": "Durata massima del prestito: 1 giorno (restituzione entro il %1$s). Dal %2$s il libro è già prenotato.", + "book_loan_blocked_day": "Giorno non disponibile (%1$s): tutte le copie sono già in prestito o prenotate.", + "book_loan_no_window": "Da questa data di inizio non è possibile alcun prestito: dal %1$s il libro è già prenotato. Scegli un'altra data.", "cd_prev_month": "Mese precedente", "cd_next_month": "Mese successivo", "snackbar_request_for_date": "Prestito richiesto per il %1$s",