Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.pinakes.app.staffe2e

import com.pinakes.app.R
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

/** Native desk follows the branch-calendar facts returned by the server. */
class CalendarLoanAlignmentFlowTest : AppFlowTest() {
private fun loan(status: String, overdue: Boolean, actions: String) = """{
"id":7,"status":"$status","is_overdue":$overdue,
"book":{"id":11,"title":"Calendar-aligned loan"},
"copy":{"id":1,"inventory_number":"INV-CALENDAR"},
"user":{"id":3,"name":"Calendar reader"},
"due_at":"2030-12-25","effective_due_at":"2030-12-27",
"renewals":0,"max_renewals":3,"days_overdue":${if (overdue) 1 else 0},
"actions":$actions
}"""

private fun desk(payload: String) {
launch {
json("GET staff/circulation/summary", "circulation_summary")
body("GET staff/loans", 200, """{"data":[$payload],"meta":{"count":1,"has_more":false},"error":null}""")
body("GET staff/loans/7", 200, """{"data":$payload,"meta":{},"error":null}""")
body("POST staff/loans/7/renew", 200, """{"data":$payload,"meta":{},"error":null}""")
}
openStaffHub()
click(text(R.string.staff_circulation_desk))
clickTag("counter-active")
click("Calendar-aligned loan")
waitText("INV-CALENDAR")
}

@Test fun firstOpenDayOffersRenewalAndSendsItToTheServer() {
desk(loan("in_ritardo", false, """["return","renew","edit","receipt"]"""))
assertFalse(exists(text(R.string.staff_loan_action_recall)))
clickTag("action-renew")
clickInDialog(text(R.string.staff_loan_action_renew))
waitRequest("POST", "staff/loans/7/renew")
assertTrue(api.recorded("POST", "staff/loans/7/renew").size == 1)
}

@Test fun genuinelyLateLoanOffersRecallAndNoRenewal() {
desk(loan("in_corso", true, """["return","recall","edit","receipt"]"""))
waitText(text(R.string.staff_loan_action_recall))
assertFalse(exists(text(R.string.staff_loan_action_renew)))
assertTrue(api.recorded("POST", "staff/loans/7/renew").isEmpty())
}
}
7 changes: 6 additions & 1 deletion app/src/main/java/com/pinakes/app/data/model/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -527,13 +527,18 @@ data class LoanItem(
@SerialName("requested_at") val requestedAt: String? = null,
@SerialName("loaned_at") val loanedAt: String? = null,
@SerialName("due_at") val dueAt: String? = null,
@SerialName("effective_due_at") val effectiveDueAt: String? = null,
// Nullable for older servers: only the server knows branch closures and library time.
@SerialName("is_overdue") val isOverdue: Boolean? = null,
// Computed in the library timezone; do not re-derive from device LocalDate.
@SerialName("due_attention") val dueAttention: Boolean = false,
@SerialName("returned_at") val returnedAt: String? = null,
val renewals: Int? = null,
// Mobile API 1.4.4 presentation hint; DELETE /loans/{id} revalidates it.
val cancellable: Boolean = false,
)
) {
val displayDueAt: String? get() = effectiveDueAt ?: dueAt
}

@Serializable
data class ReservationItem(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ data class StaffLoan(
@SerialName("requested_at") val requestedAt: String? = null,
@SerialName("loaned_at") val loanedAt: String? = null,
@SerialName("due_at") val dueAt: String? = null,
@SerialName("effective_due_at") val effectiveDueAt: String? = null,
@SerialName("is_overdue") val isOverdue: Boolean? = null,
@SerialName("pickup_deadline") val pickupDeadline: String? = null,
@SerialName("returned_at") val returnedAt: String? = null,
@SerialName("returned_late") val returnedLate: Boolean = false,
Expand All @@ -64,7 +66,10 @@ data class StaffLoan(
val actions: List<String> = emptyList(),
/** Mobile API 1.10.0: the late fine (detail and return answers); null while fines are off. */
@SerialName("late_fine") val lateFine: StaffLoanLateFine? = null,
)
) {
/** Display the usable return day; the edit form keeps the stored dueAt. */
val displayDueAt: String? get() = effectiveDueAt ?: dueAt
}

@Serializable
data class StaffReservation(
Expand Down
22 changes: 18 additions & 4 deletions app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.pinakes.app.ui.common
import androidx.annotation.StringRes
import com.pinakes.app.R
import com.pinakes.app.data.model.LoanItem
import com.pinakes.app.data.model.StaffLoan
import com.pinakes.app.ui.components.AvailabilityStatus

/**
Expand All @@ -21,6 +22,19 @@ data class StatusLabel(@param:StringRes val resId: Int?, val fallback: String? =
*/
object StatusMapping {

private fun calendarStatus(status: String, isOverdue: Boolean?): String =
if (isOverdue != null && status in setOf("in_corso", "in_ritardo")) {
if (isOverdue) "in_ritardo" else "in_corso"
} else status

fun loan(loan: LoanItem): Pair<AvailabilityStatus, StatusLabel> =
this.loan(calendarStatus(loan.status, loan.isOverdue), loan.statusLabel)

fun loan(loan: StaffLoan): Pair<AvailabilityStatus, StatusLabel> =
this.loan(calendarStatus(loan.status, loan.isOverdue), loan.statusLabel)

fun loanGroup(loan: LoanItem): LoanGroup = loanGroup(calendarStatus(loan.status, loan.isOverdue))

fun loan(stato: String, serverLabel: String? = null): Pair<AvailabilityStatus, StatusLabel> = when (stato) {
// Active, on time — available-green (this is the good state).
"in_corso" -> AvailabilityStatus.Available to StatusLabel(R.string.loan_status_on_loan)
Expand Down Expand Up @@ -87,7 +101,7 @@ object StatusMapping {

/** Server computes this in the library timezone; stale `in_corso` rows may need attention too. */
fun loanNeedsAttention(loan: LoanItem): Boolean =
loan.dueAttention || loanGroup(loan.status) == LoanGroup.Overdue
loan.dueAttention || loanGroup(loan) == LoanGroup.Overdue

enum class LoanDateKind { Overdue, Returned, Requested, Due, Borrowed }

Expand All @@ -99,13 +113,13 @@ object StatusMapping {
* requested_at must win over the requested loan interval's due date.
*/
fun loanDate(loan: LoanItem): LoanDate? {
val overdue = loanGroup(loan.status) == LoanGroup.Overdue
if (overdue) return LoanDate(LoanDateKind.Overdue, loan.dueAt)
val overdue = loanGroup(loan) == LoanGroup.Overdue
if (overdue) return LoanDate(LoanDateKind.Overdue, loan.displayDueAt)
if (loan.status in setOf("pendente", "in_attesa", "annullato", "scaduto")) {
return loan.requestedAt?.let { LoanDate(LoanDateKind.Requested, it) }
}
if (loan.returnedAt != null) return LoanDate(LoanDateKind.Returned, loan.returnedAt)
if (loan.dueAt != null) return LoanDate(LoanDateKind.Due, loan.dueAt)
if (loan.displayDueAt != null) return LoanDate(LoanDateKind.Due, loan.displayDueAt)
if (loan.loanedAt != null) return LoanDate(LoanDateKind.Borrowed, loan.loanedAt)
return null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ private fun ActiveLoanList(
}
// Group by urgency bucket, preserving the bucket order (Overdue → … → Pending).
val grouped = loans
.groupBy { StatusMapping.loanGroup(it.status) }
.groupBy { StatusMapping.loanGroup(it) }
.toSortedMap(compareBy { it.order })

LazyColumn(
Expand Down Expand Up @@ -250,7 +250,7 @@ private fun androidx.compose.foundation.lazy.LazyItemScope.LoanRow(
onBookClick: (Int) -> Unit,
onCancel: ((Int) -> Unit)? = null,
) {
val (status, statusLabel) = StatusMapping.loan(loan.status, loan.statusLabel)
val (status, statusLabel) = StatusMapping.loan(loan)
val label = statusLabel.resId?.let { stringResource(it) } ?: statusLabel.fallback
val attention = StatusMapping.loanNeedsAttention(loan)
val dateLine = when (val date = StatusMapping.loanDate(loan)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import com.pinakes.app.ui.theme.Spacing
/** Chip status + localized label for a loan state (`status_label` for states the app does not know). */
@Composable
internal fun loanChip(loan: StaffLoan): Pair<AvailabilityStatus, String?> {
val (status, label) = StatusMapping.loan(loan.status, loan.statusLabel)
val (status, label) = StatusMapping.loan(loan)
return status to (label.resId?.let { stringResource(it) } ?: label.fallback)
}

Expand All @@ -64,14 +64,17 @@ internal fun reservationChip(reservation: StaffReservation): Pair<AvailabilitySt

/** The date that matters for a desk row: overdue days, due date, pickup deadline, request date. */
@Composable
internal fun loanDateLine(loan: StaffLoan): String? = when {
loan.status == "in_ritardo" && loan.daysOverdue > 0 -> stringResource(R.string.staff_loan_days_overdue, loan.daysOverdue)
loan.status == "da_ritirare" && loan.pickupDeadline != null -> stringResource(R.string.staff_loan_pickup_by, DateFormat.date(loan.pickupDeadline))
loan.returnedAt != null -> stringResource(R.string.library_returned_on, DateFormat.date(loan.returnedAt))
loan.status == "pendente" && loan.requestedAt != null -> stringResource(R.string.library_requested_on, DateFormat.date(loan.requestedAt))
loan.status == "prenotato" && loan.loanedAt != null -> stringResource(R.string.staff_loan_starts_on, DateFormat.date(loan.loanedAt))
loan.dueAt != null -> stringResource(R.string.library_due_label, DateFormat.date(loan.dueAt))
else -> null
internal fun loanDateLine(loan: StaffLoan): String? {
val dueAt = loan.displayDueAt
return when {
(loan.isOverdue ?: (loan.status == "in_ritardo")) && loan.daysOverdue > 0 -> stringResource(R.string.staff_loan_days_overdue, loan.daysOverdue)
loan.status == "da_ritirare" && loan.pickupDeadline != null -> stringResource(R.string.staff_loan_pickup_by, DateFormat.date(loan.pickupDeadline))
loan.returnedAt != null -> stringResource(R.string.library_returned_on, DateFormat.date(loan.returnedAt))
loan.status == "pendente" && loan.requestedAt != null -> stringResource(R.string.library_requested_on, DateFormat.date(loan.requestedAt))
loan.status == "prenotato" && loan.loanedAt != null -> stringResource(R.string.staff_loan_starts_on, DateFormat.date(loan.loanedAt))
dueAt != null -> stringResource(R.string.library_due_label, DateFormat.date(dueAt))
else -> null
}
}

/** One loan at the desk: cover, title, borrower and the date that matters. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ fun StaffLoanDetailScreen(onNavigateUp: () -> Unit, onOpenUser: (Int) -> Unit, o
InfoLine(stringResource(R.string.staff_copy_inventory), loan.copy?.inventoryNumber)
InfoLine(stringResource(R.string.staff_loan_requested), loan.requestedAt?.let(DateFormat::date))
InfoLine(stringResource(R.string.staff_loan_loaned), loan.loanedAt?.let(DateFormat::date))
InfoLine(stringResource(R.string.staff_loan_due), loan.dueAt?.let(DateFormat::date))
InfoLine(stringResource(R.string.staff_loan_due), loan.displayDueAt?.let(DateFormat::date))
InfoLine(stringResource(R.string.staff_loan_pickup_deadline), loan.pickupDeadline?.let(DateFormat::date))
InfoLine(stringResource(R.string.staff_loan_returned_at), loan.returnedAt?.let(DateFormat::date))
InfoLine(stringResource(R.string.staff_loan_renewals), stringResource(R.string.staff_loan_renewals_value, loan.renewals, loan.maxRenewals))
Expand Down
99 changes: 99 additions & 0 deletions app/src/test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.pinakes.app

import com.pinakes.app.data.model.LoanItem
import com.pinakes.app.data.model.StaffLoan
import com.pinakes.app.data.staff.LoanAction
import com.pinakes.app.data.staff.LoanActions
import com.pinakes.app.ui.common.StatusMapping
import com.pinakes.app.ui.components.AvailabilityStatus
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

/** The server, not the phone clock, knows the copy branch calendar. */
class CalendarLoanAlignmentTest {
private val json = Json { ignoreUnknownKeys = true }

@Test fun firstOpenReturnDayUsesServerDateAndClearsAStaleLateStatus() {
val loan = json.decodeFromString<LoanItem>("""{
"id":7,"status":"in_ritardo","due_at":"2030-12-25",
"effective_due_at":"2030-12-27","is_overdue":false,"due_attention":false
}""")
assertEquals("2030-12-25", loan.dueAt)
assertEquals("2030-12-27", loan.displayDueAt)
assertEquals(StatusMapping.LoanGroup.OnLoan, StatusMapping.loanGroup(loan))
assertEquals(AvailabilityStatus.Available, StatusMapping.loan(loan).first)
assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Due, "2030-12-27"), StatusMapping.loanDate(loan))
assertFalse(StatusMapping.loanNeedsAttention(loan))
}

@Test fun overdueDecisionWinsOverAnUnreconciledActiveStatus() {
val loan = LoanItem(status = "in_corso", dueAt = "2030-12-25", effectiveDueAt = "2030-12-27", isOverdue = true)
assertEquals(StatusMapping.LoanGroup.Overdue, StatusMapping.loanGroup(loan))
assertEquals(AvailabilityStatus.Overdue, StatusMapping.loan(loan).first)
assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Overdue, "2030-12-27"), StatusMapping.loanDate(loan))
assertTrue(StatusMapping.loanNeedsAttention(loan))
}

@Test fun dueTodayAttentionDoesNotTurnAnOnTimeLoanIntoAnOverdueOne() {
val loan = LoanItem(status = "in_corso", isOverdue = false, dueAttention = true)
assertTrue(StatusMapping.loanNeedsAttention(loan))
assertEquals(StatusMapping.LoanGroup.OnLoan, StatusMapping.loanGroup(loan))
assertEquals(R.string.loan_status_on_loan, StatusMapping.loan(loan).second.resId)
}

@Test fun olderReaderPayloadKeepsItsPreviousStatusAndDate() {
val loan = json.decodeFromString<LoanItem>("""{"status":"in_ritardo","due_at":"2030-12-25"}""")
assertNull(loan.isOverdue)
assertNull(loan.effectiveDueAt)
assertEquals(StatusMapping.LoanGroup.Overdue, StatusMapping.loanGroup(loan))
assertEquals("2030-12-25", loan.displayDueAt)
}

@Test fun historyAndUncollectedRequestsKeepTheirOwnTimeline() {
val returned = LoanItem(status = "restituito", dueAt = "2030-12-25", effectiveDueAt = "2030-12-27", isOverdue = false, returnedAt = "2030-12-24")
assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Returned, "2030-12-24"), StatusMapping.loanDate(returned))
val pending = LoanItem(status = "pendente", requestedAt = "2030-12-20", effectiveDueAt = "2030-12-27", isOverdue = false)
assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Requested, "2030-12-20"), StatusMapping.loanDate(pending))
}

@Test fun staffCanRenewOnTheFirstOpenDayWithoutRewritingTheStoredEditDate() {
val loan = json.decodeFromString<StaffLoan>("""{
"id":7,"status":"in_ritardo","due_at":"2030-12-25",
"effective_due_at":"2030-12-27","is_overdue":false,"days_overdue":0,
"actions":["return","renew","edit","receipt"]
}""")
assertEquals("2030-12-25", loan.dueAt)
assertEquals("2030-12-27", loan.displayDueAt)
assertEquals(AvailabilityStatus.Available, StatusMapping.loan(loan).first)
assertTrue(LoanActions.of(loan).contains(LoanAction.RENEW))
assertFalse(LoanActions.of(loan).contains(LoanAction.RECALL))
}

@Test fun staffOverdueStatusUsesTheServerCalendarAndItsActionList() {
val loan = json.decodeFromString<StaffLoan>("""{
"status":"in_corso","due_at":"2030-12-25","effective_due_at":"2030-12-27",
"is_overdue":true,"days_overdue":1,"actions":["return","recall"]
}""")
assertEquals(AvailabilityStatus.Overdue, StatusMapping.loan(loan).first)
assertEquals(1, loan.daysOverdue)
assertTrue(LoanActions.of(loan).contains(LoanAction.RECALL))
assertFalse(LoanActions.of(loan).contains(LoanAction.RENEW))
}

@Test fun olderStaffPayloadKeepsItsDateStatusAndActions() {
val loan = json.decodeFromString<StaffLoan>("""{"status":"in_ritardo","due_at":"2030-12-25","actions":["recall"]}""")
assertNull(loan.isOverdue)
assertEquals("2030-12-25", loan.displayDueAt)
assertEquals(AvailabilityStatus.Overdue, StatusMapping.loan(loan).first)
assertEquals(listOf(LoanAction.RECALL), LoanActions.of(loan))
}

@Test fun futureUnknownStatusesKeepTheirServerLabel() {
val loan = LoanItem(status = "future_state", statusLabel = "Future loan", isOverdue = false)
assertEquals("Future loan", StatusMapping.loan(loan).second.fallback)
}
}
Loading