diff --git a/README.md b/README.md index 55f3ca1..1e3d41c 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,61 @@ Login, home, catalog, book detail and calendar captures use the 2026 interface o +### Admin area + +Admin and staff accounts get a native staff area when the instance runs Mobile API 1.7.0: cataloguing with ISBN scanning, the whole circulation desk and the content of the Desiderata, Archives, Periodicals, Book Club and FRBR plugins. Captured on Android 15 at 360 dp against a local instance with demo data. + +#### Circulation desk + + + + + + + + + + + + + + +
Circulation desk with counters
Circulation desk
Overdue loans list
Loans by status
Overdue loan with return, edit, reminder and receipt actions
Loan actions
Pending request with approve and reject
Approve a request
New loan form with borrower and book search
New loan
Reservations grouped by title with queue position
Reservations queue
Copy label scanner
Scan a copy label
Admin area hub
Admin area
+ +#### Cataloguing + + + + + + + + + + + + + + + + + + + + +
ISBN lookup preview from the server scrapers
Scan an ISBN
Staff book list
Books
Book editor with cover sources
Edit a record
Copies, location and labels in the book editor
Copies and labels
Author page with books and record details
Author page
Author search
Authors
Genre tree
Genres
Series search
Series
Series detail with its volumes
Series detail
Merge duplicate authors, publishers and genres
Merge duplicates
CSV and LibraryThing import
Import a file
Missing covers and enrichment
Covers and enrichment
+ +#### Plugin content + + + + + + + + +
Donation offers by status
Donation offers
Archival units with publishing filter
Archives
Periodicals desk with issue barcode lookup
Periodicals desk
Staff hub plugin section
Staff tools
+ ## Features | Area | What you get | @@ -73,6 +128,10 @@ Login, home, catalog, book detail and calendar captures use the 2026 interface o | **Profile** | Edit profile, change password, device list, theme switcher, language switcher, logout | | **Notifications** | Loan due/overdue, reservation ready, book available | | **Themes** | 2026 warm neutrals, Geist + Fraunces, complete 3D covers and paired theme colours. Light by default; pick light/dark/system in Profile | +| **Admin area** | For admin and staff accounts: cataloguing (ISBN/EAN scanner with batch mode, manual records, copies with their own codes, series, merge duplicates, label PDFs, CSV and LibraryThing import, covers and enrichment, authors, publishers, genres, shelves) and plugin content (donation offers, archives, periodicals desk, book clubs, FRBR works, digital files) | +| **Circulation desk** | Counters, loans by status, new loan with borrower search and copy scanning, approve/reject, pickup, return with copy outcome, renew, bulk extend, reminders, receipt PDF, reservations queue. Hidden while offline | +| **Offline catalogue** | Full catalogue mirrored on the device with delta sync; search, book pages, My Library and Wishlist work offline, while loan and reservation actions stay hidden until the connection is back | +| **Loans in your calendar** | A private, revocable calendar link with your loans (start to due date) and reservations, for Google Calendar, Apple Calendar, Outlook and others | | **Languages** | Italian, English, French, German, following the device locale or an in-app choice | ## Tech stack @@ -97,6 +156,7 @@ Login, home, catalog, book detail and calendar captures use the 2026 interface o ./gradlew assembleDebug # debug APK → app/build/outputs/apk/debug/app-debug.apk ./gradlew lintDebug # static analysis ./gradlew testDebugUnitTest +adb shell pm revoke com.pinakes.app android.permission.CAMERA # the camera-denied test needs it denied ./gradlew connectedDebugAndroidTest # UI regression tests, with a running emulator ./gradlew assembleRelease # R8 + resource shrinking, unsigned without release credentials ``` diff --git a/STATUS.md b/STATUS.md index 84df63d..c41e9d0 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,31 @@ # Pinakes Android — Build Status +## 1.8.0 (versionCode 22): circulation desk, book tools and plugin content for staff + +Requires Mobile API 1.7.0. Every area is gated on its own `/health` flag (`staff_circulation`, `staff_copies`, `staff_series`, `staff_merges`, `staff_labels`, `staff_imports`, `staff_covers`, `staff_desiderata`, `staff_archives`) plus the staff role and `staff_catalog`; a server that answers `false` or omits a flag keeps the 1.7.0 staff area for that part. The 403 `forbidden` contract is unchanged: every new call goes through the shared `staffCall`, which drops the stored role. + +- **Circulation desk** (hidden, never disabled, while the device is offline; an open desk screen closes when the connection drops): counters of the day, loans by bucket (requests, to pick up, scheduled, on loan, overdue, history) with search and multi-select bulk extend/recall, loan detail with the server's actions (approve, reject with reason, confirm/cancel pickup, return with the copy's outcome and a validated penalty, renew, edit user and dates, reminder, receipt PDF read in the app or shared through the FileProvider, receipt by email), new loan and new reservation (borrower search with eligibility, book search or a scanned copy code, per-user availability calendar, hand over now), reservations grouped by title (edit dates, cancel with reason, reactivate), borrower page. Creates carry an `Idempotency-Key` (one per submission, kept across the automatic retry after a dropped connection or a 409 `concurrent_retry`, discarded after success or `idempotency_conflict`). +- **Copy scanner**: the same CameraX + ZXing pipeline reading CODE 128 and CODE 39 inventory labels (and EAN-13); manual code entry always visible; the sheet shows the copy and its open loan, then returns it with an outcome, lends it, or opens the book or the loan. +- **Book tools**: per-copy list in the book editor (state, notes, delete with the server's refusal reasons, a copy with its own inventory code), book and copy label PDFs and the label settings sheet; series tree with search, details, rename (an existing name asks before merging), merge, delete, parent work, book numbers, removal and multi-select assignment; duplicate merges (suggested author groups or manual pick, publishers, genres); CSV and LibraryThing imports picked with the system document picker and run chunk by chunk from the server's progress (out-of-order recovery, busy retry, resumable after process death, pause/resume/cancel, error lines, past imports with their error CSV); missing covers fetched one book at a time with progress and the rate limit honoured; server-side cover sync and enrichment batches on the long-timeout client behind a confirmation; automatic enrichment switch. +- **Plugin content**: Desiderata offers by status (accept, reject, receive into the wanted title or a picked record, delete) and wanted titles marked received; Archives units (published or not) created and edited with the plugin's rules checked client-side, publish switch, authorities with roles, cover and document uploads, delete; authority records list and editor. +- Local verification: 377 debug unit tests (new: tolerant 1.7.0 parsing, loan actions, penalty validation, copy codes and export file names, staff areas × flags × connectivity, idempotency keys, series tree and series/label forms, import chunk rules, archive/authority forms and offer actions), zero lint errors (111 warnings, all pre-existing), debug and R8 release builds. +- Device tests: 92 instrumented tests on Android 15 (`staffe2e` with `FakePinakes`). New flows: return as damaged with penalty, invalid penalty not sent, borrower page, 409 message, new loan with user search and Idempotency-Key, ineligible borrower, return by typed copy code, unknown code, reservation cancel and create, circulation hidden offline and restored, desk screen closing offline, no section without flags; copy edit, refused delete, copy with code, taken code; series number and rename clash merge; suggested author merge; import resume with out-of-order recovery and an upload through the real system document picker; covers loop and enrichment switch; desiderata receive (linked and into a picked record) and closed offer; archive publish keeping every field and create after validation; a retried merge already done; digital upload through the system picker and its absence without the flag; review regressions (bulk recall in batches with Select all, reservation paging, an authority that failed to load cannot be saved, a failed duplicate list with Retry, an import that ended elsewhere is not resumable, exports deleted on sign out); Tier 2 (a typed issue code received, a masthead's issues and a new year, a club created and a member's role changed, a workflow edited, a work created with an expression and a linked book, no Tier 2 rows without their flags). +- Live smoke (`tools/e2e/live_smoke.py`) gains: loan created by API with an Idempotency-Key and its replay then returned in the app by typing the copy code, a copy with an explicit code from the book editor, a series created in the app, a two-row CSV import followed to completion, a free Desiderata offer received into a picked record, an unpublished archive unit absent from the public list. Each phase 2 step is skipped while its flag is off; cleanup returns loans the run left open and deletes only the books, series, units and offers the run created. Tier 2 steps: an unknown code at the periodicals desk (read-only, the bridge has no deletes), a book club and an FRBR work created in the app (deleted in cleanup). Run on 2026-10-14 against the local server (Mobile API 1.7.0 branch, every staff flag on) on an Android 15 emulator: 19 passed, 0 skipped, 0 failed. +- **Tier 2** (each behind its own flag: `staff_periodicals`, `staff_bookclub`, `staff_frbr`, `staff_digital_library`): periodicals desk (scan or type an arriving issue's barcode, then receive or claim it; mastheads with years, issue states, new years and issues, subscriptions); book club management (create, settings, reading workflow with reorderable states and voting/reading/archived marks, members' role and membership state, adding a borrower, delete); FRBR works (search, create, edit, delete, expressions, link and unlink books); eBook and audiobook upload from the book editor (streamed, long-timeout client). The periodicals bridge has no article routes, so articles stay on the website. + +## 1.7.0 (versionCode 20): staff catalogue, offline catalogue, loans calendar + +Requires Mobile API 1.6.0 (Pinakes 0.8.5). Every new entry point is gated on the matching `/health` feature, so an older server keeps showing exactly the 1.6.2 app. + +- **Staff area** (`staff_catalog` + role `admin`/`staff`, refreshed from login and `GET /me`; a 403 on any staff call drops the stored role and leaves the screen): Profile → Staff area, and a scan button on the Catalog tab. Books (list with search, edit, delete with the active-circulation refusal), a shared create/edit form for records with or without an ISBN (identifiers validated live, authors/publishers pickers with "add new", genre/subgenre, shelf/level with automatic position, cover from URL / photo / picked image, copies), authors, publishers, genres (tree, find-or-create) and shelves/levels. Edits are true patches: only changed fields are sent. +- **ISBN scanner**: CameraX + ZXing EAN-13 (no Play services), checksum validation, two identical reads before a lookup, 3 s suppression, batch mode with remembered copies, shelf/level and optional auto-confirm, preview / duplicate ("add copies", "open existing") / not-found sheets. Manual ISBN/EAN entry is always visible (emulators, denied permission). CAMERA is requested only there, with rationale and a settings fallback; `camera.any` is optional. +- **Offline catalogue**: Room v2 holds the whole catalogue, kept current by `GET /catalog/sync` (delta with a server watermark, forced full resync every 7 days or when the server asks); loans, reservations and wishlist are written through on every fetch. Without a connection, search, book pages, Library and Wishlist answer from the cache with an offline banner. Loan and reservation actions are hidden offline (never shown disabled) and cached availability is shown only as "last known on ". The cache is excluded from backups. +- **Loans calendar**: Profile → Loans in your calendar (`calendar_feed`): create the private ICS link, copy it (primary, Google Calendar can only subscribe from its website), open it as `webcal://` with an HTTPS fallback, regenerate (old link stops) or turn it off. +- Local verification: 283 debug unit tests (new: ISBN/EAN codec, scan debouncer, staff access, staff form and write bodies, sync merge, offline policy, calendar links, tolerant 1.6.0 parsing, Room v2 catalogue/library DAOs), zero lint errors (111 warnings, mostly newer-dependency notices), debug and R8 release builds. +- Device tests (re-runnable): `app/src/androidTest/java/com/pinakes/app/staffe2e/` drives the real app (MainActivity, real Hilt graph, ViewModels, Room) against `FakePinakes`, a MockWebServer on the device loopback answering JSON fixtures from `app/src/androidTest/assets/fixtures/` and recording every request. Covered: staff entry only for admin/staff with `staff_catalog` (hidden for standard users, for the flag off and after a server-side downgrade, header included); scanner manual ISBN → preview → add → counter, duplicate → add copies, not found → manual form with the code, invalid code rejected, camera denied keeps manual entry (the camera prompt is denied through UiAutomator); manual form title required and shelf-without-level both send nothing, edit sends only the changed key (recorded PUT body); offline search banner with cached results and no availability or loan actions, offline detail "last known" wording without loan buttons, Library/Wishlist offline empty state with no raw exception, going offline after an online load hides the loan button and coming back restores it (real airplane mode); calendar create → masked link, copy flagged sensitive, regenerate and turn off both confirm; gender and tax code absent from the profile editor when the library hides them (present when shown) and never asked at registration; author detail with the padded info card and the author's books (`author_id` sent); loan-calendar month (7 weekday columns, every day cell inside the screen and inside a 320dp column, month navigation) and the birth-date picker within a 360dp screen. Run on a disposable emulator (it replaces the app's stored session and cache): `ANDROID_SERIAL=emulator-5554 ./gradlew connectedDebugAndroidTest` (one class: add `-Pandroid.testInstrumentationRunnerArguments.class=com.pinakes.app.staffe2e.ScannerFlowTest`). +- Live smoke (real app, real server): `tools/e2e/live-smoke.sh [--install]` with `PINAKES_E2E_URL` (as the device reaches it, e.g. `http://10.0.2.2:8081`), `PINAKES_E2E_EMAIL`, `PINAKES_E2E_PASSWORD` (admin or staff), optional `PINAKES_E2E_HOST_URL`, `PINAKES_E2E_ISBN` (default 9788845292613, must not be catalogued) and `ANDROID_SERIAL`. It clears the app, onboards, signs in, opens the staff hub, scan-adds an ISBN with 2 copies, adds a copy through the duplicate sheet, creates a record without ISBN, sets its shelf/level (skipped when the instance has none) and checks nothing else changed, searches and opens it in airplane mode, creates the calendar link and fetches the feed, and (with `PINAKES_E2E_DB_NAME/USER/PASSWORD/SOCKET` or `_HOST/_PORT`) hides gender and tax code in the settings table, checks they vanish from `/me` and from the profile editor, then restores the previous values; every step is checked through the API. Cleanup removes only what the run created (books, a calendar link that did not exist before, device tokens issued during the run) and clears the app. Texts follow the device language through `i18n/*.json`; `tools/e2e/ui.py` is the adb/uiautomator driver (`python3 tools/e2e/ui.py dump`). +- Not covered by automation: real camera decoding and photo capture (emulators have no barcode in view). + ## 2026 restyle (2026-10-08) The app now uses the 2026 web design: Geist / Fraunces, warm neutrals, a server-ready `ThemePalette`, complete 3D book covers, a searchable home hero with a cover fan, catalog grid/list views, dot status pills, grouped availability/actions and one card per exposed digital asset. Shared tokens apply to all existing account and plugin screens. The five-section bottom navigation is retained. Article lists and details now show server-resolved covers, subtitles, published online resources and access conditions, with a website action for the existing staff management flows. Native collections require the additive Mobile API 1.5.0 server update; existing circulation and authentication routes are retained. @@ -58,7 +84,7 @@ It follows the **device locale** by default and offers an **in-app language switcher** in Profile (System default / Italiano / English / Français / Deutsch) via `AppCompatDelegate.setApplicationLocales(...)`, persisted across restarts (`autoStoreLocales`). -- **Single source of truth = JSON.** Translations live in `i18n/{en,it,fr,de}.json` (622 keys each, +- **Single source of truth = JSON.** Translations live in `i18n/{en,it,fr,de}.json` (815 keys each, en = default/source). A Gradle task (`GenerateI18nResTask`) generates `res/values*/strings.xml` from those JSONs at build time, so the app uses standard Android string resources but the editable source stays JSON — syncable with the web app's `locale/*.json`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 66e715b..70b3161 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -166,8 +166,8 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" minSdk = 26 targetSdk = 35 - versionCode = 19 - versionName = "1.6.2" + versionCode = 22 + versionName = "1.8.0" vectorDrawables { useSupportLibrary = true @@ -287,6 +287,8 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.okhttp.mockwebserver) + // System permission dialogs (camera) and airplane mode in the staff/offline device tests. + androidTestImplementation(libs.androidx.test.uiautomator) implementation(libs.androidx.navigation.compose) @@ -317,6 +319,13 @@ dependencies { implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.work.runtime.ktx) + // Staff ISBN scanning: CameraX preview/analysis + ZXing EAN-13 decoding. + // ZXing is pure Java and needs no Google Play services. + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + implementation(libs.zxing.core) + // Hilt — dependency injection (trial: Home slice migrated, ServiceLocator bridges the rest). implementation(libs.hilt.android) kapt(libs.hilt.compiler) diff --git a/app/src/androidTest/assets/fixtures/archive_10.json b/app/src/androidTest/assets/fixtures/archive_10.json new file mode 100644 index 0000000..f92fd81 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/archive_10.json @@ -0,0 +1,40 @@ +{ + "data": { + "id": 10, + "parent_id": null, + "title": "Diari", + "reference_code": "IT-PIN-9", + "level": "fonds", + "cover_url": null, + "published": true, + "fields": { + "reference_code": "IT-PIN-9", + "institution_code": "PINAKES", + "level": "fonds", + "formal_title": "", + "constructed_title": "Diari", + "date_start": null, + "date_end": null, + "extent": "", + "scope_content": "", + "language_codes": "ita", + "parent_id": null, + "specific_material": "text", + "dimensions": "", + "color_mode": "", + "photographer": "", + "publisher": "", + "collection_name": "", + "local_classification": "", + "iiif_manifest_url": "", + "rights_statement_url": "", + "ark_identifier": "", + "version_note": "", + "published": 0 + }, + "files": [], + "authorities": [] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/archive_3.json b/app/src/androidTest/assets/fixtures/archive_3.json new file mode 100644 index 0000000..be6c01a --- /dev/null +++ b/app/src/androidTest/assets/fixtures/archive_3.json @@ -0,0 +1,54 @@ +{ + "data": { + "id": 3, + "parent_id": null, + "title": "Fondo Rossi", + "reference_code": "IT-PIN-1", + "level": "fonds", + "cover_url": null, + "published": false, + "fields": { + "reference_code": "IT-PIN-1", + "institution_code": "PINAKES", + "level": "fonds", + "formal_title": "", + "constructed_title": "Fondo Rossi", + "date_start": 1920, + "date_end": 1950, + "extent": "3 buste", + "scope_content": "Carte di famiglia", + "language_codes": "ita", + "parent_id": null, + "specific_material": "text", + "dimensions": "", + "color_mode": "", + "photographer": "", + "publisher": "", + "collection_name": "", + "local_classification": "", + "iiif_manifest_url": "", + "rights_statement_url": "", + "ark_identifier": "", + "version_note": "", + "published": 0 + }, + "files": [ + { + "id": 9, + "url": "/uploads/archives/9.pdf", + "mime": "application/pdf", + "label": "inventario.pdf" + } + ], + "authorities": [ + { + "id": 2, + "type": "person", + "name": "Rossi, Mario", + "role": "creator" + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/archive_3_published.json b/app/src/androidTest/assets/fixtures/archive_3_published.json new file mode 100644 index 0000000..8be5ed8 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/archive_3_published.json @@ -0,0 +1,54 @@ +{ + "data": { + "id": 3, + "parent_id": null, + "title": "Fondo Rossi", + "reference_code": "IT-PIN-1", + "level": "fonds", + "cover_url": null, + "published": true, + "fields": { + "reference_code": "IT-PIN-1", + "institution_code": "PINAKES", + "level": "fonds", + "formal_title": "", + "constructed_title": "Fondo Rossi", + "date_start": 1920, + "date_end": 1950, + "extent": "3 buste", + "scope_content": "Carte di famiglia", + "language_codes": "ita", + "parent_id": null, + "specific_material": "text", + "dimensions": "", + "color_mode": "", + "photographer": "", + "publisher": "", + "collection_name": "", + "local_classification": "", + "iiif_manifest_url": "", + "rights_statement_url": "", + "ark_identifier": "", + "version_note": "", + "published": 1 + }, + "files": [ + { + "id": 9, + "url": "/uploads/archives/9.pdf", + "mime": "application/pdf", + "label": "inventario.pdf" + } + ], + "authorities": [ + { + "id": 2, + "type": "person", + "name": "Rossi, Mario", + "role": "creator" + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/archive_lookups.json b/app/src/androidTest/assets/fixtures/archive_lookups.json new file mode 100644 index 0000000..481ffef --- /dev/null +++ b/app/src/androidTest/assets/fixtures/archive_lookups.json @@ -0,0 +1,64 @@ +{ + "data": { + "levels": [ + { + "value": "fonds", + "label": "Fondo" + }, + { + "value": "series", + "label": "Serie" + }, + { + "value": "file", + "label": "Fascicolo" + }, + { + "value": "item", + "label": "Unità" + } + ], + "specific_materials": [ + { + "value": "text", + "label": "Testo" + }, + { + "value": "photograph", + "label": "Fotografia" + } + ], + "color_modes": [ + { + "value": "bw", + "label": "Bianco e nero" + } + ], + "authority_types": [ + { + "value": "person", + "label": "Persona" + }, + { + "value": "corporate", + "label": "Ente" + }, + { + "value": "family", + "label": "Famiglia" + } + ], + "authority_roles": [ + { + "value": "creator", + "label": "Produttore" + }, + { + "value": "subject", + "label": "Soggetto" + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/archives_staff.json b/app/src/androidTest/assets/fixtures/archives_staff.json new file mode 100644 index 0000000..c6bbfc4 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/archives_staff.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "id": 3, + "parent_id": null, + "title": "Fondo Rossi", + "reference_code": "IT-PIN-1", + "level": "fonds", + "date_start": 1920, + "date_end": 1950, + "cover_url": null, + "published": false + }, + { + "id": 4, + "parent_id": 3, + "title": "Lettere", + "reference_code": "IT-PIN-2", + "level": "series", + "date_start": 1920, + "date_end": 1950, + "cover_url": null, + "published": true + } + ], + "meta": { + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/authorities_list.json b/app/src/androidTest/assets/fixtures/authorities_list.json new file mode 100644 index 0000000..f39a2e5 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/authorities_list.json @@ -0,0 +1,16 @@ +{ + "data": [ + { + "id": 7, + "type": "person", + "name": "Rossi, Mario", + "dates": "1880-1950", + "units_count": 2 + } + ], + "meta": { + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/authority_7.json b/app/src/androidTest/assets/fixtures/authority_7.json new file mode 100644 index 0000000..c323de7 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/authority_7.json @@ -0,0 +1,19 @@ +{ + "data": { + "id": 7, + "type": "person", + "authorised_form": "Rossi, Mario", + "dates_of_existence": "1880-1950", + "history": "Notaio.", + "units": [ + { + "id": 3, + "title": "Fondo Rossi", + "role": "creator" + } + ], + "authors": [] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/book_1.json b/app/src/androidTest/assets/fixtures/book_1.json new file mode 100644 index 0000000..ab471f0 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/book_1.json @@ -0,0 +1,35 @@ +{ + "data": { + "id": 1, + "title": "Il barone rampante", + "year": 1957, + "language": "Italiano", + "pages": 200, + "isbn13": "9788804668237", + "media_type": "libro", + "cover_url": null, + "publisher": "Einaudi", + "authors": [ + { + "id": 3, + "name": "Italo Calvino", + "role": "principale" + } + ], + "availability": { + "copies_total": 2, + "copies_available": 1, + "loanable_now": true, + "state": "available" + }, + "personal_history": { + "has_read": false, + "has_reserved": false, + "has_wishlisted": false, + "has_active_loan": false, + "has_pending_request": false + } + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/book_work_link.json b/app/src/androidTest/assets/fixtures/book_work_link.json new file mode 100644 index 0000000..afd3831 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/book_work_link.json @@ -0,0 +1,12 @@ +{ + "data": { + "book_id": 5, + "work": { + "id": 2, + "uniform_title": "Odissea" + }, + "expression_id": null + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/bulk_recall_10.json b/app/src/androidTest/assets/fixtures/bulk_recall_10.json new file mode 100644 index 0000000..3251567 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/bulk_recall_10.json @@ -0,0 +1,8 @@ +{ + "data": { + "sent": 9, + "skipped": 1 + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/bulk_recall_50.json b/app/src/androidTest/assets/fixtures/bulk_recall_50.json new file mode 100644 index 0000000..f4ec775 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/bulk_recall_50.json @@ -0,0 +1,8 @@ +{ + "data": { + "sent": 50, + "skipped": 0 + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/calendar_disabled.json b/app/src/androidTest/assets/fixtures/calendar_disabled.json new file mode 100644 index 0000000..6d3f74d --- /dev/null +++ b/app/src/androidTest/assets/fixtures/calendar_disabled.json @@ -0,0 +1,11 @@ +{ + "data": { + "enabled": false, + "feed_url": null, + "webcal_url": null, + "created_at": null, + "rotated_at": null + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/calendar_enabled.json b/app/src/androidTest/assets/fixtures/calendar_enabled.json new file mode 100644 index 0000000..d470cc9 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/calendar_enabled.json @@ -0,0 +1,11 @@ +{ + "data": { + "enabled": true, + "feed_url": "http://localhost/api/v1/calendar/feed/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.ics", + "webcal_url": "webcal://localhost/api/v1/calendar/feed/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.ics", + "created_at": "2026-10-13 10:00:00", + "rotated_at": null + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/circulation_summary.json b/app/src/androidTest/assets/fixtures/circulation_summary.json new file mode 100644 index 0000000..fcf9584 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/circulation_summary.json @@ -0,0 +1,22 @@ +{ + "data": { + "pending": 2, + "pickup_ready": 1, + "scheduled": 0, + "active": 5, + "overdue": 3, + "reservations": 4, + "due_today": 1, + "returned_today": 2, + "today": "2026-10-14", + "settings": { + "loan_duration_days": 30, + "pickup_expiry_days": 3, + "max_renewals": 2, + "max_active_loans_per_user": 5, + "allow_multiple_loans_same_book": false + } + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/club_1132.json b/app/src/androidTest/assets/fixtures/club_1132.json new file mode 100644 index 0000000..64b9d26 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/club_1132.json @@ -0,0 +1,57 @@ +{ + "data": { + "id": 1132, + "slug": "lettori", + "name": "Lettori del giovedì", + "description": "", + "rules": "", + "color": "#4f46e5", + "privacy": "public", + "max_members": null, + "active": true, + "moderate_proposals": false, + "max_proposals_per_member": null, + "modules": [], + "member_count": 1, + "created_at": "2026-10-13 17:21:02", + "workflow": [ + { + "key": "proposed", + "label": "Proposto", + "color": "#6b7280", + "flags": { + "votable": true + } + }, + { + "key": "voting", + "label": "In votazione", + "color": "#8b5cf6", + "flags": { + "voting": true + } + }, + { + "key": "reading", + "label": "In lettura", + "color": "#10b981", + "flags": { + "current": true + } + } + ], + "members": [ + { + "id": 5, + "user_id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "role": "member", + "role_name": "Membro", + "status": "active" + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/club_member_5_moderator.json b/app/src/androidTest/assets/fixtures/club_member_5_moderator.json new file mode 100644 index 0000000..270f909 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/club_member_5_moderator.json @@ -0,0 +1,13 @@ +{ + "data": { + "id": 5, + "user_id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "role": "moderator", + "role_name": "Moderatore", + "status": "active" + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/clubs_list.json b/app/src/androidTest/assets/fixtures/clubs_list.json new file mode 100644 index 0000000..6d4ed18 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/clubs_list.json @@ -0,0 +1,22 @@ +{ + "data": [ + { + "id": 1132, + "slug": "lettori", + "name": "Lettori del giovedì", + "description": "", + "rules": "", + "color": "#4f46e5", + "privacy": "public", + "max_members": null, + "active": true, + "moderate_proposals": false, + "max_proposals_per_member": null, + "modules": [], + "member_count": 1, + "created_at": "2026-10-13 17:21:02" + } + ], + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/copies_added.json b/app/src/androidTest/assets/fixtures/copies_added.json new file mode 100644 index 0000000..52106e5 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/copies_added.json @@ -0,0 +1,14 @@ +{ + "data": { + "copies_total": 3, + "copies_available": 3, + "created": [ + { + "id": 99, + "inventory_number": "LIB-42-C3" + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/copies_added_code.json b/app/src/androidTest/assets/fixtures/copies_added_code.json new file mode 100644 index 0000000..690c477 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/copies_added_code.json @@ -0,0 +1,14 @@ +{ + "data": { + "copies_total": 3, + "copies_available": 2, + "created": [ + { + "id": 13, + "inventory_number": "LIB-7" + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/copy_by_code_found.json b/app/src/androidTest/assets/fixtures/copy_by_code_found.json new file mode 100644 index 0000000..45c83d4 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/copy_by_code_found.json @@ -0,0 +1,78 @@ +{ + "data": { + "found": true, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato", + "status_label": "In prestito", + "notes": null, + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "held": false, + "deletable": false, + "on_loan": { + "loan_id": 7, + "user_name": "Ada Rossi", + "due_at": "2026-10-31" + }, + "available": false, + "loan": { + "id": 7, + "status": "in_corso", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 0, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "renew", + "edit", + "receipt", + "email_receipt" + ] + } + } + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/copy_by_code_missing.json b/app/src/androidTest/assets/fixtures/copy_by_code_missing.json new file mode 100644 index 0000000..30434a1 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/copy_by_code_missing.json @@ -0,0 +1,8 @@ +{ + "data": { + "found": false, + "copy": null + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/cover_fetched.json b/app/src/androidTest/assets/fixtures/cover_fetched.json new file mode 100644 index 0000000..5317149 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/cover_fetched.json @@ -0,0 +1,9 @@ +{ + "data": { + "fetched": true, + "cover_url": "/uploads/copertine/5.jpg", + "reason": null + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/digital_uploaded.json b/app/src/androidTest/assets/fixtures/digital_uploaded.json new file mode 100644 index 0000000..ffe8dc3 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/digital_uploaded.json @@ -0,0 +1,8 @@ +{ + "data": { + "file_url": "/uploads/digital/5.pdf", + "audio_url": null + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/empty_list.json b/app/src/androidTest/assets/fixtures/empty_list.json new file mode 100644 index 0000000..86d8eb6 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/empty_list.json @@ -0,0 +1,5 @@ +{ + "data": [], + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/enrich_cron_on.json b/app/src/androidTest/assets/fixtures/enrich_cron_on.json new file mode 100644 index 0000000..7ed824f --- /dev/null +++ b/app/src/androidTest/assets/fixtures/enrich_cron_on.json @@ -0,0 +1,7 @@ +{ + "data": { + "enabled": true + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/enrich_overview.json b/app/src/androidTest/assets/fixtures/enrich_overview.json new file mode 100644 index 0000000..f706255 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/enrich_overview.json @@ -0,0 +1,13 @@ +{ + "data": { + "stats": { + "total_with_isbn": 120, + "missing_cover": 2, + "missing_description": 7, + "pending": 9 + }, + "cron_enabled": false + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/expression_created.json b/app/src/androidTest/assets/fixtures/expression_created.json new file mode 100644 index 0000000..7650749 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/expression_created.json @@ -0,0 +1,13 @@ +{ + "data": { + "id": 3, + "work_id": 2, + "type": "testo", + "language": "ita", + "title": "Odissea (trad. Calzecchi Onesti)", + "year": 1963, + "notes": null + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/health_no_staff.json b/app/src/androidTest/assets/fixtures/health_no_staff.json new file mode 100644 index 0000000..adc4327 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/health_no_staff.json @@ -0,0 +1,28 @@ +{ + "data": { + "name": "Test Library", + "version": "0.8.5", + "api_version": "v1", + "features": { + "catalog": true, + "loans": true, + "reservations": true, + "wishlist": true, + "messages": true, + "notifications": true, + "push": false, + "reviews": false, + "archives": false, + "desiderata": false, + "staff_catalog": false, + "catalog_sync": true, + "calendar_feed": true + }, + "catalogue_mode": false, + "loan_approval_required": true, + "app_access_enabled": true, + "registration_enabled": false + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/health_registration_hidden.json b/app/src/androidTest/assets/fixtures/health_registration_hidden.json new file mode 100644 index 0000000..51fec8c --- /dev/null +++ b/app/src/androidTest/assets/fixtures/health_registration_hidden.json @@ -0,0 +1,32 @@ +{ + "data": { + "name": "Test Library", + "version": "0.8.5", + "api_version": "v1", + "features": { + "catalog": true, + "loans": true, + "reservations": true, + "wishlist": true, + "messages": true, + "notifications": true, + "push": false, + "reviews": false, + "archives": false, + "desiderata": false, + "staff_catalog": true, + "catalog_sync": true, + "calendar_feed": true + }, + "catalogue_mode": false, + "loan_approval_required": true, + "app_access_enabled": true, + "registration_enabled": true, + "registration": { + "show_sesso": false, + "show_cod_fiscale": false + } + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/health_staff.json b/app/src/androidTest/assets/fixtures/health_staff.json new file mode 100644 index 0000000..18b3c4a --- /dev/null +++ b/app/src/androidTest/assets/fixtures/health_staff.json @@ -0,0 +1,42 @@ +{ + "data": { + "name": "Test Library", + "version": "0.8.5", + "api_version": "v1", + "features": { + "catalog": true, + "loans": true, + "reservations": true, + "wishlist": true, + "messages": true, + "notifications": true, + "push": false, + "reviews": false, + "archives": false, + "desiderata": false, + "staff_catalog": true, + "catalog_sync": true, + "calendar_feed": true, + "staff_circulation": true, + "staff_copies": true, + "staff_series": true, + "staff_merges": true, + "staff_labels": true, + "staff_imports": true, + "staff_covers": true, + "staff_desiderata": true, + "staff_archives": true, + "staff_periodicals": false, + "staff_bookclub": false, + "staff_frbr": false, + "staff_digital_library": false, + "staff_librarything": false + }, + "catalogue_mode": false, + "loan_approval_required": true, + "app_access_enabled": true, + "registration_enabled": false + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/health_staff_digital.json b/app/src/androidTest/assets/fixtures/health_staff_digital.json new file mode 100644 index 0000000..f4f4ce5 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/health_staff_digital.json @@ -0,0 +1,42 @@ +{ + "data": { + "name": "Test Library", + "version": "0.8.5", + "api_version": "v1", + "features": { + "catalog": true, + "loans": true, + "reservations": true, + "wishlist": true, + "messages": true, + "notifications": true, + "push": false, + "reviews": false, + "archives": false, + "desiderata": false, + "staff_catalog": true, + "catalog_sync": true, + "calendar_feed": true, + "staff_circulation": true, + "staff_copies": true, + "staff_series": true, + "staff_merges": true, + "staff_labels": true, + "staff_imports": true, + "staff_covers": true, + "staff_desiderata": true, + "staff_archives": true, + "staff_periodicals": false, + "staff_bookclub": false, + "staff_frbr": false, + "staff_digital_library": true, + "staff_librarything": false + }, + "catalogue_mode": false, + "loan_approval_required": true, + "app_access_enabled": true, + "registration_enabled": false + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/health_staff_phase1.json b/app/src/androidTest/assets/fixtures/health_staff_phase1.json new file mode 100644 index 0000000..1ac9d0d --- /dev/null +++ b/app/src/androidTest/assets/fixtures/health_staff_phase1.json @@ -0,0 +1,28 @@ +{ + "data": { + "name": "Test Library", + "version": "0.8.5", + "api_version": "v1", + "features": { + "catalog": true, + "loans": true, + "reservations": true, + "wishlist": true, + "messages": true, + "notifications": true, + "push": false, + "reviews": false, + "archives": false, + "desiderata": false, + "staff_catalog": true, + "catalog_sync": true, + "calendar_feed": true + }, + "catalogue_mode": false, + "loan_approval_required": true, + "app_access_enabled": true, + "registration_enabled": false + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/health_staff_tier2.json b/app/src/androidTest/assets/fixtures/health_staff_tier2.json new file mode 100644 index 0000000..0567d60 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/health_staff_tier2.json @@ -0,0 +1,42 @@ +{ + "data": { + "name": "Test Library", + "version": "0.8.5", + "api_version": "v1", + "features": { + "catalog": true, + "loans": true, + "reservations": true, + "wishlist": true, + "messages": true, + "notifications": true, + "push": false, + "reviews": false, + "archives": false, + "desiderata": false, + "staff_catalog": true, + "catalog_sync": true, + "calendar_feed": true, + "staff_circulation": true, + "staff_copies": true, + "staff_series": true, + "staff_merges": true, + "staff_labels": true, + "staff_imports": true, + "staff_covers": true, + "staff_desiderata": true, + "staff_archives": true, + "staff_periodicals": true, + "staff_bookclub": true, + "staff_frbr": true, + "staff_digital_library": false, + "staff_librarything": false + }, + "catalogue_mode": false, + "loan_approval_required": true, + "app_access_enabled": true, + "registration_enabled": false + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/import_job_chunk_10.json b/app/src/androidTest/assets/fixtures/import_job_chunk_10.json new file mode 100644 index 0000000..70eb45e --- /dev/null +++ b/app/src/androidTest/assets/fixtures/import_job_chunk_10.json @@ -0,0 +1,25 @@ +{ + "data": { + "id": "6f1c2a", + "kind": "csv", + "file_name": "libri.csv", + "status": "running", + "total_rows": 30, + "processed": 10, + "chunk_size": 10, + "enable_scraping": false, + "imported": 10, + "updated": 0, + "scraped": 0, + "authors_created": 0, + "publishers_created": 0, + "errors": 0, + "error_details": [], + "complete": false, + "created_at": "2026-10-14 09:00:00", + "updated_at": "2026-10-14 09:00:00", + "expires_at": "2026-10-15 09:00:00" + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/import_job_chunk_30.json b/app/src/androidTest/assets/fixtures/import_job_chunk_30.json new file mode 100644 index 0000000..fd5ba17 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/import_job_chunk_30.json @@ -0,0 +1,31 @@ +{ + "data": { + "id": "6f1c2a", + "kind": "csv", + "file_name": "libri.csv", + "status": "completed", + "total_rows": 30, + "processed": 30, + "chunk_size": 10, + "enable_scraping": false, + "imported": 28, + "updated": 0, + "scraped": 0, + "authors_created": 0, + "publishers_created": 0, + "errors": 2, + "error_details": [ + "Row 12: missing title", + { + "row": "Row 25", + "message": "bad ISBN" + } + ], + "complete": true, + "created_at": "2026-10-14 09:00:00", + "updated_at": "2026-10-14 09:00:00", + "expires_at": "2026-10-15 09:00:00" + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/import_job_created.json b/app/src/androidTest/assets/fixtures/import_job_created.json new file mode 100644 index 0000000..0d1f173 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/import_job_created.json @@ -0,0 +1,25 @@ +{ + "data": { + "id": "6f1c2a", + "kind": "csv", + "file_name": "libri.csv", + "status": "ready", + "total_rows": 30, + "processed": 0, + "chunk_size": 10, + "enable_scraping": false, + "imported": 0, + "updated": 0, + "scraped": 0, + "authors_created": 0, + "publishers_created": 0, + "errors": 0, + "error_details": [], + "complete": false, + "created_at": "2026-10-14 09:00:00", + "updated_at": "2026-10-14 09:00:00", + "expires_at": "2026-10-15 09:00:00" + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/import_out_of_order_20.json b/app/src/androidTest/assets/fixtures/import_out_of_order_20.json new file mode 100644 index 0000000..538ef99 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/import_out_of_order_20.json @@ -0,0 +1,10 @@ +{ + "data": null, + "meta": { + "processed": 20 + }, + "error": { + "code": "out_of_order", + "message": "Chunk out of order" + } +} diff --git a/app/src/androidTest/assets/fixtures/imports_overview.json b/app/src/androidTest/assets/fixtures/imports_overview.json new file mode 100644 index 0000000..5823216 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/imports_overview.json @@ -0,0 +1,47 @@ +{ + "data": { + "jobs": [ + { + "id": "6f1c2a", + "kind": "csv", + "file_name": "libri.csv", + "status": "ready", + "total_rows": 30, + "processed": 0, + "chunk_size": 10, + "enable_scraping": false, + "imported": 0, + "updated": 0, + "scraped": 0, + "authors_created": 0, + "publishers_created": 0, + "errors": 0, + "error_details": [], + "complete": false, + "created_at": "2026-10-14 09:00:00", + "updated_at": "2026-10-14 09:00:00", + "expires_at": "2026-10-15 09:00:00" + } + ], + "history": [ + { + "import_id": "41", + "kind": "csv", + "file_name": "old.csv", + "user": { + "id": 1, + "name": "Admin" + }, + "total_rows": 5, + "imported": 4, + "updated": 0, + "failed": 1, + "status": "completed", + "started_at": "2026-10-01 10:00:00", + "completed_at": "2026-10-01 10:01:00" + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/isbn_duplicate.json b/app/src/androidTest/assets/fixtures/isbn_duplicate.json new file mode 100644 index 0000000..73b91d0 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/isbn_duplicate.json @@ -0,0 +1,35 @@ +{ + "data": { + "identifier": { + "input": "9788845292613", + "isbn10": "8845292614", + "isbn13": "9788845292613", + "ean": null, + "kind": "isbn" + }, + "found": true, + "existing": { + "id": 42, + "title": "Il nome della rosa", + "copies_total": 2, + "location": "A.1.4" + }, + "preview": { + "title": "Il nome della rosa", + "authors": [ + "Umberto Eco" + ], + "publisher": "Bompiani", + "year": "1980", + "pages": "503", + "cover_url": null, + "isbn13": "9788845292613", + "source": "test" + }, + "sources_checked": [ + "test" + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/isbn_found.json b/app/src/androidTest/assets/fixtures/isbn_found.json new file mode 100644 index 0000000..40b273f --- /dev/null +++ b/app/src/androidTest/assets/fixtures/isbn_found.json @@ -0,0 +1,30 @@ +{ + "data": { + "identifier": { + "input": "9788845292613", + "isbn10": "8845292614", + "isbn13": "9788845292613", + "ean": null, + "kind": "isbn" + }, + "found": true, + "existing": null, + "preview": { + "title": "Il nome della rosa", + "authors": [ + "Umberto Eco" + ], + "publisher": "Bompiani", + "year": "1980", + "pages": "503", + "cover_url": null, + "isbn13": "9788845292613", + "source": "test" + }, + "sources_checked": [ + "test" + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/isbn_not_found.json b/app/src/androidTest/assets/fixtures/isbn_not_found.json new file mode 100644 index 0000000..ec1d041 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/isbn_not_found.json @@ -0,0 +1,17 @@ +{ + "data": { + "identifier": { + "input": "4006381333931", + "ean": "4006381333931", + "kind": "ean" + }, + "found": false, + "existing": null, + "preview": null, + "sources_checked": [ + "test" + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/issue_4_received.json b/app/src/androidTest/assets/fixtures/issue_4_received.json new file mode 100644 index 0000000..2b3cfc3 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/issue_4_received.json @@ -0,0 +1,16 @@ +{ + "data": { + "id": 4, + "periodical_id": 16, + "periodical": "Internazionale", + "year_id": 2, + "year": 2026, + "number": "12", + "date": "2026-10-09", + "status": "posseduto", + "claimed_at": null, + "claims": 0 + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/loans_empty.json b/app/src/androidTest/assets/fixtures/loans_empty.json new file mode 100644 index 0000000..19cb151 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/loans_empty.json @@ -0,0 +1,9 @@ +{ + "data": { + "pending": [], + "active": [], + "history": [] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/me_admin.json b/app/src/androidTest/assets/fixtures/me_admin.json new file mode 100644 index 0000000..acaa648 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/me_admin.json @@ -0,0 +1,12 @@ +{ + "data": { + "id": 1, + "nome": "Ada", + "cognome": "Admin", + "email": "ada@example.org", + "tipo_utente": "admin", + "email_verificata": true + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/me_fields_hidden.json b/app/src/androidTest/assets/fixtures/me_fields_hidden.json new file mode 100644 index 0000000..d789a0b --- /dev/null +++ b/app/src/androidTest/assets/fixtures/me_fields_hidden.json @@ -0,0 +1,17 @@ +{ + "data": { + "id": 1, + "nome": "Ada", + "cognome": "Admin", + "email": "ada@example.org", + "tipo_utente": "admin", + "email_verificata": true, + "data_nascita": "1980-01-01", + "hidden_fields": [ + "sesso", + "cod_fiscale" + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/me_fields_visible.json b/app/src/androidTest/assets/fixtures/me_fields_visible.json new file mode 100644 index 0000000..dc5b1ff --- /dev/null +++ b/app/src/androidTest/assets/fixtures/me_fields_visible.json @@ -0,0 +1,16 @@ +{ + "data": { + "id": 1, + "nome": "Ada", + "cognome": "Admin", + "email": "ada@example.org", + "tipo_utente": "admin", + "email_verificata": true, + "sesso": "F", + "cod_fiscale": "RSSMRA80A01H501U", + "data_nascita": "1980-01-01", + "hidden_fields": [] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/me_staff.json b/app/src/androidTest/assets/fixtures/me_staff.json new file mode 100644 index 0000000..412dfc8 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/me_staff.json @@ -0,0 +1,12 @@ +{ + "data": { + "id": 1, + "nome": "Ada", + "cognome": "Admin", + "email": "ada@example.org", + "tipo_utente": "staff", + "email_verificata": true + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/me_user.json b/app/src/androidTest/assets/fixtures/me_user.json new file mode 100644 index 0000000..2b485f2 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/me_user.json @@ -0,0 +1,12 @@ +{ + "data": { + "id": 1, + "nome": "Ugo", + "cognome": "Admin", + "email": "ada@example.org", + "tipo_utente": "utente", + "email_verificata": true + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/merge_result.json b/app/src/androidTest/assets/fixtures/merge_result.json new file mode 100644 index 0000000..f105727 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/merge_result.json @@ -0,0 +1,7 @@ +{ + "data": { + "primary_id": 3 + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/missing_covers.json b/app/src/androidTest/assets/fixtures/missing_covers.json new file mode 100644 index 0000000..d3f5427 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/missing_covers.json @@ -0,0 +1,20 @@ +{ + "data": [ + { + "id": 5, + "title": "Orlando", + "isbn13": "9780156701600" + }, + { + "id": 6, + "title": "Il barone rampante", + "isbn13": "9788804668237" + } + ], + "meta": { + "total_count": 2, + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/offer_41_accepted.json b/app/src/androidTest/assets/fixtures/offer_41_accepted.json new file mode 100644 index 0000000..e2262be --- /dev/null +++ b/app/src/androidTest/assets/fixtures/offer_41_accepted.json @@ -0,0 +1,25 @@ +{ + "data": { + "id": 41, + "status": "accepted", + "status_label": "In attesa", + "donor_name": "Carla Bianchi", + "donor_email": "carla@example.org", + "title": "Orlando", + "author": "Virginia Woolf", + "publisher": null, + "isbn": null, + "notes": "Good condition", + "book": { + "id": 5, + "title": "Orlando" + }, + "received_book": null, + "copy_id": null, + "source": "mobile", + "created_at": "2026-10-12 10:00:00", + "received_at": null + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/offer_41_received.json b/app/src/androidTest/assets/fixtures/offer_41_received.json new file mode 100644 index 0000000..c36f438 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/offer_41_received.json @@ -0,0 +1,28 @@ +{ + "data": { + "id": 41, + "status": "received", + "status_label": "In attesa", + "donor_name": "Carla Bianchi", + "donor_email": "carla@example.org", + "title": "Orlando", + "author": "Virginia Woolf", + "publisher": null, + "isbn": null, + "notes": "Good condition", + "book": { + "id": 5, + "title": "Orlando" + }, + "received_book": { + "id": 5, + "title": "Orlando" + }, + "copy_id": 77, + "source": "mobile", + "created_at": "2026-10-12 10:00:00", + "received_at": "2026-10-14 11:00:00" + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/offer_42_received.json b/app/src/androidTest/assets/fixtures/offer_42_received.json new file mode 100644 index 0000000..fe5b1ef --- /dev/null +++ b/app/src/androidTest/assets/fixtures/offer_42_received.json @@ -0,0 +1,25 @@ +{ + "data": { + "id": 42, + "status": "received", + "status_label": "In attesa", + "donor_name": "Carla Bianchi", + "donor_email": "carla@example.org", + "title": "Un libro qualunque", + "author": "Anonimo", + "publisher": null, + "isbn": null, + "notes": "Good condition", + "book": null, + "received_book": { + "id": 5, + "title": "Orlando" + }, + "copy_id": 78, + "source": "mobile", + "created_at": "2026-10-12 10:00:00", + "received_at": null + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/offers_pending.json b/app/src/androidTest/assets/fixtures/offers_pending.json new file mode 100644 index 0000000..1665f20 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/offers_pending.json @@ -0,0 +1,49 @@ +{ + "data": [ + { + "id": 41, + "status": "pending", + "status_label": "In attesa", + "donor_name": "Carla Bianchi", + "donor_email": "carla@example.org", + "title": "Orlando", + "author": "Virginia Woolf", + "publisher": null, + "isbn": null, + "notes": "Good condition", + "book": { + "id": 5, + "title": "Orlando" + }, + "received_book": null, + "copy_id": null, + "source": "mobile", + "created_at": "2026-10-12 10:00:00", + "received_at": null + }, + { + "id": 42, + "status": "pending", + "status_label": "In attesa", + "donor_name": "Carla Bianchi", + "donor_email": "carla@example.org", + "title": "Un libro qualunque", + "author": "Anonimo", + "publisher": null, + "isbn": null, + "notes": "Good condition", + "book": null, + "received_book": null, + "copy_id": null, + "source": "mobile", + "created_at": "2026-10-12 10:00:00", + "received_at": null + } + ], + "meta": { + "count": 2, + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/periodical_16.json b/app/src/androidTest/assets/fixtures/periodical_16.json new file mode 100644 index 0000000..86fdca0 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/periodical_16.json @@ -0,0 +1,22 @@ +{ + "data": { + "id": 16, + "title": "Internazionale", + "issn": "1122-2832", + "type": "rivista", + "frequency": "settimanale", + "holdings": "1993-", + "years": [ + { + "id": 2, + "year": 2026, + "volume": "33", + "bound": false, + "issues_count": 2, + "owned_count": 1 + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/periodicals_list.json b/app/src/androidTest/assets/fixtures/periodicals_list.json new file mode 100644 index 0000000..9830c7e --- /dev/null +++ b/app/src/androidTest/assets/fixtures/periodicals_list.json @@ -0,0 +1,18 @@ +{ + "data": [ + { + "id": 16, + "title": "Internazionale", + "issn": "1122-2832", + "type": "rivista", + "frequency": "settimanale", + "years_count": 2, + "issues_count": 14 + } + ], + "meta": { + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/registration_fields_hidden.json b/app/src/androidTest/assets/fixtures/registration_fields_hidden.json new file mode 100644 index 0000000..6531b22 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/registration_fields_hidden.json @@ -0,0 +1,28 @@ +{ + "data": { + "registration_enabled": true, + "builtin_fields": { + "cognome": { + "required": true, + "configurable": false + } + }, + "custom_fields": [], + "profile_fields": { + "data_nascita": { + "visible": true, + "configurable": false + }, + "sesso": { + "visible": false, + "configurable": true + }, + "cod_fiscale": { + "visible": false, + "configurable": true + } + } + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/registration_fields_visible.json b/app/src/androidTest/assets/fixtures/registration_fields_visible.json new file mode 100644 index 0000000..e670809 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/registration_fields_visible.json @@ -0,0 +1,28 @@ +{ + "data": { + "registration_enabled": true, + "builtin_fields": { + "cognome": { + "required": true, + "configurable": false + } + }, + "custom_fields": [], + "profile_fields": { + "data_nascita": { + "visible": true, + "configurable": false + }, + "sesso": { + "visible": true, + "configurable": true + }, + "cod_fiscale": { + "visible": true, + "configurable": true + } + } + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/scan_issue.json b/app/src/androidTest/assets/fixtures/scan_issue.json new file mode 100644 index 0000000..0531700 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/scan_issue.json @@ -0,0 +1,20 @@ +{ + "data": { + "found": true, + "match": "issue", + "action": "receive", + "issue": { + "id": 4, + "numero": "12", + "stato": "atteso", + "annata_id": 2, + "anno": 2026, + "testata_id": 16, + "titolo": "Internazionale", + "url": "x" + }, + "message": "Fascicolo n. 12 in attesa" + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/search_books.json b/app/src/androidTest/assets/fixtures/search_books.json new file mode 100644 index 0000000..8a232c7 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/search_books.json @@ -0,0 +1,41 @@ +{ + "data": [ + { + "id": 1, + "title": "Il barone rampante", + "author": "Italo Calvino", + "publisher": "Einaudi", + "genre": "Romanzo", + "year": 1957, + "language": "Italiano", + "media_type": "libro", + "isbn13": "9788804668237", + "cover_url": null, + "copies_total": 2, + "copies_available": 1, + "loanable_now": true + }, + { + "id": 2, + "title": "La città invisibile", + "author": "Anna Rossi", + "publisher": "Adelphi", + "genre": "Saggio", + "year": 2001, + "language": "Italiano", + "media_type": "libro", + "isbn13": "9780140186475", + "cover_url": null, + "copies_total": 1, + "copies_available": 0, + "loanable_now": false + } + ], + "meta": { + "count": 2, + "limit": 20, + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_author_3.json b/app/src/androidTest/assets/fixtures/staff_author_3.json new file mode 100644 index 0000000..41fc805 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_author_3.json @@ -0,0 +1,19 @@ +{ + "data": { + "id": 3, + "name": "Virginia Woolf", + "pseudonym": null, + "born": "1882-01-25", + "died": "1941-03-28", + "nationality": "britannica", + "biography": null, + "website": null, + "photo_url": null, + "gnd_id": "118635506", + "books_count": 1, + "created_at": "2026-01-10 09:00:00", + "updated_at": "2026-10-01 18:30:00" + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_author_duplicates.json b/app/src/androidTest/assets/fixtures/staff_author_duplicates.json new file mode 100644 index 0000000..16b1ca2 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_author_duplicates.json @@ -0,0 +1,18 @@ +{ + "data": [ + [ + { + "id": 3, + "name": "Virginia Woolf", + "books_count": 4 + }, + { + "id": 8, + "name": "Woolf, Virginia", + "books_count": 1 + } + ] + ], + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_authors.json b/app/src/androidTest/assets/fixtures/staff_authors.json new file mode 100644 index 0000000..c75a7b4 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_authors.json @@ -0,0 +1,17 @@ +{ + "data": [ + { + "id": 3, + "name": "Virginia Woolf", + "pseudonym": null, + "label": "Virginia Woolf (1882–1941)", + "born": "1882-01-25", + "died": "1941-03-28" + } + ], + "meta": { + "count": 1, + "limit": 20 + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_book_5.json b/app/src/androidTest/assets/fixtures/staff_book_5.json new file mode 100644 index 0000000..e04a64d --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_book_5.json @@ -0,0 +1,48 @@ +{ + "data": { + "id": 5, + "title": "Orlando", + "subtitle": "A Biography", + "isbn13": "9780156701600", + "year": 1928, + "pages": 333, + "language": "English", + "media_type": "libro", + "copies_total": 2, + "copies_available": 2, + "genre": { + "id": 11, + "name": "Romanzo" + }, + "authors": [ + { + "id": 1, + "name": "Virginia Woolf", + "role": "principale" + }, + { + "id": 7, + "name": "Nadia Fusini", + "role": "traduttore" + } + ], + "publishers": [ + { + "id": 4, + "name": "Hogarth" + } + ], + "location": { + "shelf_id": 7, + "shelf_code": "A", + "level_id": 70, + "level_number": 1, + "position": 3, + "label": "A.1.3" + }, + "copies": [], + "is_wanted": false + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_book_5_copies.json b/app/src/androidTest/assets/fixtures/staff_book_5_copies.json new file mode 100644 index 0000000..20ad349 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_book_5_copies.json @@ -0,0 +1,61 @@ +{ + "data": { + "id": 5, + "title": "Orlando", + "subtitle": "A Biography", + "isbn13": "9780156701600", + "year": 1928, + "pages": 333, + "language": "English", + "media_type": "libro", + "copies_total": 2, + "copies_available": 1, + "genre": { + "id": 11, + "name": "Romanzo" + }, + "authors": [ + { + "id": 1, + "name": "Virginia Woolf", + "role": "principale" + }, + { + "id": 7, + "name": "Nadia Fusini", + "role": "traduttore" + } + ], + "publishers": [ + { + "id": 4, + "name": "Hogarth" + } + ], + "location": { + "shelf_id": 7, + "shelf_code": "A", + "level_id": 70, + "level_number": 1, + "position": 3, + "label": "A.1.3" + }, + "copies": [ + { + "id": 11, + "inventory_number": "INV-0011", + "status": "disponibile", + "notes": null + }, + { + "id": 12, + "inventory_number": "INV-0012", + "status": "prestato", + "notes": "Spine worn" + } + ], + "is_wanted": false + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_book_availability.json b/app/src/androidTest/assets/fixtures/staff_book_availability.json new file mode 100644 index 0000000..27dd420 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_book_availability.json @@ -0,0 +1,137 @@ +{ + "data": { + "total_copies": 2, + "earliest_available": "2026-10-14", + "unavailable_dates": [], + "days": [ + { + "date": "2026-10-14", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-15", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-16", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-17", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-18", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-19", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-20", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-21", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-22", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-23", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-24", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-25", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-26", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-27", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-28", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-29", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-30", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-31", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_book_created.json b/app/src/androidTest/assets/fixtures/staff_book_created.json new file mode 100644 index 0000000..41ac6c7 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_book_created.json @@ -0,0 +1,12 @@ +{ + "data": { + "id": 42, + "title": "Il nome della rosa", + "copies_total": 1, + "copies_available": 1, + "authors": [], + "publishers": [] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_books.json b/app/src/androidTest/assets/fixtures/staff_books.json new file mode 100644 index 0000000..aee877e --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_books.json @@ -0,0 +1,21 @@ +{ + "data": [ + { + "id": 5, + "title": "Orlando", + "author": "Virginia Woolf", + "isbn13": "9780156701600", + "copies_total": 2, + "copies_available": 2, + "is_wanted": false, + "updated_at": "2026-10-13 09:00:00" + } + ], + "meta": { + "count": 1, + "limit": 30, + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_copy_11.json b/app/src/androidTest/assets/fixtures/staff_copy_11.json new file mode 100644 index 0000000..f62e523 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_copy_11.json @@ -0,0 +1,17 @@ +{ + "data": { + "id": 11, + "inventory_number": "INV-0011", + "status": "manutenzione", + "status_label": "In manutenzione", + "notes": "Cover loose", + "book": { + "id": 5, + "title": "Orlando" + }, + "held": false, + "deletable": true + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_genres.json b/app/src/androidTest/assets/fixtures/staff_genres.json new file mode 100644 index 0000000..fe0acb4 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_genres.json @@ -0,0 +1,19 @@ +{ + "data": [ + { + "id": 10, + "name": "Narrativa", + "parent_id": null, + "children": [ + { + "id": 11, + "name": "Romanzo", + "parent_id": 10, + "children": [] + } + ] + } + ], + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_loan_7.json b/app/src/androidTest/assets/fixtures/staff_loan_7.json new file mode 100644 index 0000000..a8f3db7 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_loan_7.json @@ -0,0 +1,54 @@ +{ + "data": { + "id": 7, + "status": "in_corso", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 0, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "renew", + "edit", + "receipt", + "email_receipt" + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_loan_7_returned.json b/app/src/androidTest/assets/fixtures/staff_loan_7_returned.json new file mode 100644 index 0000000..7195c27 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_loan_7_returned.json @@ -0,0 +1,50 @@ +{ + "data": { + "id": 7, + "status": "danneggiato", + "status_label": "Danneggiato", + "origin": "diretto", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "danneggiato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": "2026-10-14", + "returned_late": false, + "days_overdue": 0, + "renewals": 0, + "max_renewals": 2, + "penalty": 12.5, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "receipt" + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_loan_created.json b/app/src/androidTest/assets/fixtures/staff_loan_created.json new file mode 100644 index 0000000..ecd6847 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_loan_created.json @@ -0,0 +1,54 @@ +{ + "data": { + "id": 21, + "status": "in_corso", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 0, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "renew", + "edit", + "receipt", + "email_receipt" + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_loans_active.json b/app/src/androidTest/assets/fixtures/staff_loans_active.json new file mode 100644 index 0000000..33dee2e --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_loans_active.json @@ -0,0 +1,111 @@ +{ + "data": [ + { + "id": 7, + "status": "in_corso", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 0, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "renew", + "edit", + "receipt", + "email_receipt" + ] + }, + { + "id": 8, + "status": "in_ritardo", + "status_label": "In ritardo", + "origin": "diretto", + "book": { + "id": 6, + "title": "Il barone rampante", + "cover_url": null, + "isbn13": null + }, + "copy": { + "id": 12, + "inventory_number": "INV-0012", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-01", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 13, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "edit", + "recall", + "receipt", + "email_receipt" + ] + } + ], + "meta": { + "count": 2, + "limit": 30, + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_loans_overdue_60.json b/app/src/androidTest/assets/fixtures/staff_loans_overdue_60.json new file mode 100644 index 0000000..9c9e37f --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_loans_overdue_60.json @@ -0,0 +1,2831 @@ +{ + "data": [ + { + "id": 1000, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2000, + "title": "Overdue 00", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1001, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2001, + "title": "Overdue 01", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1002, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2002, + "title": "Overdue 02", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1003, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2003, + "title": "Overdue 03", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1004, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2004, + "title": "Overdue 04", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1005, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2005, + "title": "Overdue 05", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1006, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2006, + "title": "Overdue 06", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1007, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2007, + "title": "Overdue 07", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1008, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2008, + "title": "Overdue 08", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1009, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2009, + "title": "Overdue 09", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1010, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2010, + "title": "Overdue 10", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1011, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2011, + "title": "Overdue 11", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1012, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2012, + "title": "Overdue 12", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1013, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2013, + "title": "Overdue 13", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1014, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2014, + "title": "Overdue 14", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1015, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2015, + "title": "Overdue 15", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1016, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2016, + "title": "Overdue 16", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1017, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2017, + "title": "Overdue 17", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1018, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2018, + "title": "Overdue 18", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1019, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2019, + "title": "Overdue 19", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1020, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2020, + "title": "Overdue 20", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1021, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2021, + "title": "Overdue 21", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1022, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2022, + "title": "Overdue 22", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1023, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2023, + "title": "Overdue 23", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1024, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2024, + "title": "Overdue 24", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1025, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2025, + "title": "Overdue 25", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1026, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2026, + "title": "Overdue 26", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1027, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2027, + "title": "Overdue 27", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1028, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2028, + "title": "Overdue 28", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1029, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2029, + "title": "Overdue 29", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1030, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2030, + "title": "Overdue 30", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1031, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2031, + "title": "Overdue 31", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1032, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2032, + "title": "Overdue 32", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1033, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2033, + "title": "Overdue 33", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1034, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2034, + "title": "Overdue 34", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1035, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2035, + "title": "Overdue 35", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1036, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2036, + "title": "Overdue 36", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1037, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2037, + "title": "Overdue 37", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1038, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2038, + "title": "Overdue 38", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1039, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2039, + "title": "Overdue 39", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1040, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2040, + "title": "Overdue 40", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1041, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2041, + "title": "Overdue 41", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1042, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2042, + "title": "Overdue 42", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1043, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2043, + "title": "Overdue 43", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1044, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2044, + "title": "Overdue 44", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1045, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2045, + "title": "Overdue 45", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1046, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2046, + "title": "Overdue 46", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1047, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2047, + "title": "Overdue 47", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1048, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2048, + "title": "Overdue 48", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1049, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2049, + "title": "Overdue 49", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1050, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2050, + "title": "Overdue 50", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1051, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2051, + "title": "Overdue 51", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1052, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2052, + "title": "Overdue 52", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1053, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2053, + "title": "Overdue 53", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1054, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2054, + "title": "Overdue 54", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1055, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2055, + "title": "Overdue 55", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1056, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2056, + "title": "Overdue 56", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1057, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2057, + "title": "Overdue 57", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1058, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2058, + "title": "Overdue 58", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1059, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2059, + "title": "Overdue 59", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + } + ], + "meta": { + "count": 60, + "limit": 100, + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_lookups.json b/app/src/androidTest/assets/fixtures/staff_lookups.json new file mode 100644 index 0000000..9807797 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_lookups.json @@ -0,0 +1,73 @@ +{ + "data": { + "languages": [ + { + "code": "it", + "label": "Italiano" + } + ], + "media_types": [ + { + "value": "libro", + "label": "Libro" + }, + { + "value": "altro", + "label": "Altro" + } + ], + "formats": [ + { + "value": "Brossura", + "label": "Brossura" + } + ], + "acquisition_types": [ + { + "value": "Acquisto", + "label": "Acquisto" + } + ], + "contributor_roles": [], + "copy_states": [ + { + "value": "disponibile", + "label": "Disponibile", + "editable": true + }, + { + "value": "prestato", + "label": "In prestito", + "editable": false + }, + { + "value": "manutenzione", + "label": "In manutenzione", + "editable": true + }, + { + "value": "perso", + "label": "Perso", + "editable": true + } + ], + "series_types": [ + { + "value": "serie", + "label": "Serie" + }, + { + "value": "universo", + "label": "Universo" + } + ], + "return_outcomes": [ + { + "value": "restituito", + "label": "Restituito" + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_reservation_31_cancelled.json b/app/src/androidTest/assets/fixtures/staff_reservation_31_cancelled.json new file mode 100644 index 0000000..10cf3ee --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_reservation_31_cancelled.json @@ -0,0 +1,32 @@ +{ + "data": { + "id": 31, + "status": "annullata", + "status_label": "Annullata", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "queue_position": 1, + "reserved_at": "2026-10-10", + "expires_at": "2026-11-10", + "requested_from": "2026-11-01", + "requested_to": "2026-11-30", + "notified": false, + "created_at": "2026-10-10 09:00:00", + "actions": [ + "reactivate" + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_reservations.json b/app/src/androidTest/assets/fixtures/staff_reservations.json new file mode 100644 index 0000000..8be538c --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_reservations.json @@ -0,0 +1,69 @@ +{ + "data": [ + { + "id": 31, + "status": "attiva", + "status_label": "Attiva", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "queue_position": 1, + "reserved_at": "2026-10-10", + "expires_at": "2026-11-10", + "requested_from": "2026-11-01", + "requested_to": "2026-11-30", + "notified": false, + "created_at": "2026-10-10 09:00:00", + "actions": [ + "edit", + "cancel" + ] + }, + { + "id": 32, + "status": "attiva", + "status_label": "Attiva", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "user": { + "id": 4, + "name": "Bruno Sospeso", + "email": null, + "card_number": "C-0013", + "status": "sospeso" + }, + "queue_position": 2, + "reserved_at": "2026-10-10", + "expires_at": "2026-11-10", + "requested_from": "2026-11-01", + "requested_to": "2026-11-30", + "notified": false, + "created_at": "2026-10-10 09:00:00", + "actions": [ + "edit", + "cancel" + ] + } + ], + "meta": { + "count": 2, + "limit": 50, + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_reservations_page1.json b/app/src/androidTest/assets/fixtures/staff_reservations_page1.json new file mode 100644 index 0000000..fc67ba1 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_reservations_page1.json @@ -0,0 +1,69 @@ +{ + "data": [ + { + "id": 31, + "status": "attiva", + "status_label": "Attiva", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "queue_position": 1, + "reserved_at": "2026-10-10", + "expires_at": "2026-11-10", + "requested_from": "2026-11-01", + "requested_to": "2026-11-30", + "notified": false, + "created_at": "2026-10-10 09:00:00", + "actions": [ + "edit", + "cancel" + ] + }, + { + "id": 32, + "status": "attiva", + "status_label": "Attiva", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "user": { + "id": 4, + "name": "Bruno Sospeso", + "email": null, + "card_number": "C-0013", + "status": "sospeso" + }, + "queue_position": 2, + "reserved_at": "2026-10-10", + "expires_at": "2026-11-10", + "requested_from": "2026-11-01", + "requested_to": "2026-11-30", + "notified": false, + "created_at": "2026-10-10 09:00:00", + "actions": [ + "edit", + "cancel" + ] + } + ], + "meta": { + "count": 2, + "limit": 50, + "next_cursor": "Mg", + "has_more": true + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_reservations_page2.json b/app/src/androidTest/assets/fixtures/staff_reservations_page2.json new file mode 100644 index 0000000..b720788 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_reservations_page2.json @@ -0,0 +1,39 @@ +{ + "data": [ + { + "id": 33, + "status": "attiva", + "status_label": "Attiva", + "book": { + "id": 9, + "title": "Page two title", + "cover_url": null + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "queue_position": 1, + "reserved_at": "2026-10-10", + "expires_at": "2026-11-10", + "requested_from": "2026-11-01", + "requested_to": "2026-11-30", + "notified": false, + "created_at": "2026-10-10 09:00:00", + "actions": [ + "edit", + "cancel" + ] + } + ], + "meta": { + "count": 1, + "limit": 50, + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_series.json b/app/src/androidTest/assets/fixtures/staff_series.json new file mode 100644 index 0000000..70cbbe3 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_series.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "id": 4, + "name": "Discworld", + "type": "universo", + "book_count": 3, + "complete": false, + "children": [ + { + "id": 5, + "name": "Rincewind", + "type": "serie", + "parent_id": 4, + "book_count": 2, + "children": [] + } + ] + }, + { + "id": 6, + "name": "Gialli Mondadori", + "type": "serie", + "book_count": 1, + "complete": true, + "children": [] + } + ], + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_series_5.json b/app/src/androidTest/assets/fixtures/staff_series_5.json new file mode 100644 index 0000000..45d0e54 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_series_5.json @@ -0,0 +1,39 @@ +{ + "data": { + "id": 5, + "name": "Rincewind", + "type": "serie", + "parent_id": 4, + "parent_name": "Discworld", + "group": null, + "cycle": null, + "cycle_order": null, + "complete": false, + "book_count": 2, + "min_number": "1", + "max_number": "2", + "children": [], + "description": "The wizard.", + "books": [ + { + "id": 5, + "title": "Orlando", + "cover_url": null, + "series_number": "1", + "is_primary": true, + "year": "1928" + }, + { + "id": 7, + "title": "Sourcery", + "cover_url": null, + "series_number": null, + "is_primary": true, + "year": "1988" + } + ], + "related": [] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_series_5_numbered.json b/app/src/androidTest/assets/fixtures/staff_series_5_numbered.json new file mode 100644 index 0000000..7d5ea65 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_series_5_numbered.json @@ -0,0 +1,39 @@ +{ + "data": { + "id": 5, + "name": "Rincewind", + "type": "serie", + "parent_id": 4, + "parent_name": "Discworld", + "group": null, + "cycle": null, + "cycle_order": null, + "complete": false, + "book_count": 2, + "min_number": "1", + "max_number": "2", + "children": [], + "description": "The wizard.", + "books": [ + { + "id": 5, + "title": "Orlando", + "cover_url": null, + "series_number": "1", + "is_primary": true, + "year": "1928" + }, + { + "id": 7, + "title": "Sourcery", + "cover_url": null, + "series_number": "2", + "is_primary": true, + "year": "1988" + } + ], + "related": [] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_shelves.json b/app/src/androidTest/assets/fixtures/staff_shelves.json new file mode 100644 index 0000000..10877e1 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_shelves.json @@ -0,0 +1,20 @@ +{ + "data": [ + { + "id": 7, + "code": "A", + "name": "Narrativa", + "order": 1, + "levels": [ + { + "id": 70, + "number": 1, + "description": "Alto", + "order": 1 + } + ] + } + ], + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_user_3.json b/app/src/androidTest/assets/fixtures/staff_user_3.json new file mode 100644 index 0000000..d542e18 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_user_3.json @@ -0,0 +1,100 @@ +{ + "data": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo", + "card_expires_at": "2027-12-31", + "role": "standard", + "active_loans": 1, + "active_reservations": 1, + "eligible": true, + "eligibility_reason": null, + "loans": [ + { + "id": 7, + "status": "in_corso", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 0, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "renew", + "edit", + "receipt", + "email_receipt" + ] + } + ], + "reservations": [ + { + "id": 31, + "status": "attiva", + "status_label": "Attiva", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "queue_position": 1, + "reserved_at": "2026-10-10", + "expires_at": "2026-11-10", + "requested_from": "2026-11-01", + "requested_to": "2026-11-30", + "notified": false, + "created_at": "2026-10-10 09:00:00", + "actions": [ + "edit", + "cancel" + ] + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_users.json b/app/src/androidTest/assets/fixtures/staff_users.json new file mode 100644 index 0000000..65e502f --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_users.json @@ -0,0 +1,26 @@ +{ + "data": [ + { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + { + "id": 4, + "name": "Bruno Sospeso", + "email": "bruno@example.org", + "card_number": "C-0013", + "status": "sospeso", + "card_expires_at": "2027-01-01", + "role": "standard", + "active_loans": 0, + "active_reservations": 0, + "eligible": false, + "eligibility_reason": "Suspended" + } + ], + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/subscriptions_empty.json b/app/src/androidTest/assets/fixtures/subscriptions_empty.json new file mode 100644 index 0000000..86d8eb6 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/subscriptions_empty.json @@ -0,0 +1,5 @@ +{ + "data": [], + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/sync_full.json b/app/src/androidTest/assets/fixtures/sync_full.json new file mode 100644 index 0000000..619ba45 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/sync_full.json @@ -0,0 +1,62 @@ +{ + "data": { + "items": [ + { + "id": 1, + "change": "upsert", + "changed_at": "2026-10-13 09:00:00", + "book": { + "id": 1, + "title": "Il barone rampante", + "author": "Italo Calvino", + "publisher": "Einaudi", + "genre": "Romanzo", + "year": 1957, + "language": "Italiano", + "media_type": "libro", + "isbn13": "9788804668237", + "cover_url": null, + "copies_total": 2, + "copies_available": 1, + "loanable_now": true, + "pages": 200, + "description": "Descrizione.", + "location_label": "A.1.3", + "updated_at": "2026-10-13 09:00:00" + } + }, + { + "id": 2, + "change": "upsert", + "changed_at": "2026-10-13 09:00:00", + "book": { + "id": 2, + "title": "La città invisibile", + "author": "Anna Rossi", + "publisher": "Adelphi", + "genre": "Saggio", + "year": 2001, + "language": "Italiano", + "media_type": "libro", + "isbn13": "9780140186475", + "cover_url": null, + "copies_total": 1, + "copies_available": 0, + "loanable_now": false, + "pages": 200, + "description": "Descrizione.", + "location_label": "A.1.3", + "updated_at": "2026-10-13 09:00:00" + } + } + ], + "server_time": "2026-10-13 10:00:00", + "full_resync": true + }, + "meta": { + "next_cursor": null, + "has_more": false, + "count": 2 + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/work_2.json b/app/src/androidTest/assets/fixtures/work_2.json new file mode 100644 index 0000000..f18e235 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/work_2.json @@ -0,0 +1,21 @@ +{ + "data": { + "id": 2, + "uniform_title": "Odissea", + "original_title": "Ὀδύσσεια", + "main_author_id": null, + "main_author_name": null, + "created_from": -725, + "created_to": null, + "original_language": "grc", + "viaf_work_id": null, + "wikidata_id": null, + "notes": null, + "slug": "odissea", + "editions_count": 0, + "editions": [], + "expressions": [] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/works_list.json b/app/src/androidTest/assets/fixtures/works_list.json new file mode 100644 index 0000000..57086b3 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/works_list.json @@ -0,0 +1,28 @@ +{ + "data": [ + { + "id": 2, + "uniform_title": "Odissea", + "original_title": "Ὀδύσσεια", + "main_author_id": null, + "main_author_name": null, + "created_from": -725, + "created_to": null, + "original_language": "grc", + "viaf_work_id": null, + "wikidata_id": null, + "notes": null, + "slug": "odissea", + "editions_count": 0, + "editions": [], + "expressions": [] + } + ], + "meta": { + "count": 1, + "limit": 100, + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/year_2_issues.json b/app/src/androidTest/assets/fixtures/year_2_issues.json new file mode 100644 index 0000000..184ecf5 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/year_2_issues.json @@ -0,0 +1,18 @@ +{ + "data": [ + { + "id": 4, + "number": "12", + "status": "atteso", + "publication_date": "2026-10-09" + }, + { + "id": 5, + "number": "11", + "status": "posseduto", + "publication_date": "2026-10-02" + } + ], + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/year_created.json b/app/src/androidTest/assets/fixtures/year_created.json new file mode 100644 index 0000000..21142bb --- /dev/null +++ b/app/src/androidTest/assets/fixtures/year_created.json @@ -0,0 +1,11 @@ +{ + "data": { + "id": 9, + "periodical_id": 16, + "year": 2027, + "volume": "34", + "bound": false + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/java/com/pinakes/app/CalendarLayoutTest.kt b/app/src/androidTest/java/com/pinakes/app/CalendarLayoutTest.kt new file mode 100644 index 0000000..189db7b --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/CalendarLayoutTest.kt @@ -0,0 +1,87 @@ +package com.pinakes.app + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import androidx.test.platform.app.InstrumentationRegistry +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.data.model.AvailabilityDay +import com.pinakes.app.ui.screens.detail.LoanCalendar +import com.pinakes.app.ui.screens.detail.LoanRequestSheet +import com.pinakes.app.ui.theme.PinakesTheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import java.time.LocalDate +import java.time.YearMonth + +/** + * The loan calendar's month grid fits a phone: 7 weekday columns and every day of the month + * inside the screen (and inside a 320dp column), months navigable. The test device is 360dp + * wide. + */ +class CalendarLayoutTest { + @get:Rule val compose = createComposeRule() + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + private fun calendar(): AvailabilityCalendar { + val today = LocalDate.now() + val days = (0 until 120).map { i -> + val d = today.plusDays(i.toLong()) + AvailabilityDay(date = d.toString(), available = if (i % 9 == 0) 0 else 1, state = if (i % 9 == 0) "full" else "free") + } + return AvailabilityCalendar(totalCopies = 2, earliestAvailable = today.toString(), days = days) + } + + private fun assertMonthInside(month: YearMonth, container: Rect) { + compose.onNodeWithTag("calendar-weekdays").onChildren().assertCountEquals(7) + for (day in 1..month.lengthOfMonth()) { + val node = compose.onNodeWithTag("calendar-day-${month.atDay(day)}", useUnmergedTree = true).fetchSemanticsNode() + val b = node.boundsInRoot + assertTrue("day $day of $month outside ($b vs $container)", + b.left >= container.left - 0.5f && b.right <= container.right + 0.5f && b.top >= container.top - 0.5f && b.width > 0f) + } + } + + @Test fun loanDialogMonthFitsThePhoneScreenAndNavigates() { + 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 + assertTrue("dialog wider than the screen: $dialog", dialog.left >= 0f && dialog.right <= screen.right) + val month = YearMonth.now() + assertMonthInside(month, dialog) + compose.onNodeWithContentDescription(context.getString(R.string.cd_next_month)).performClick() + compose.waitForIdle() + assertMonthInside(month.plusMonths(1), dialog) + compose.onNodeWithContentDescription(context.getString(R.string.cd_prev_month)).performClick() + compose.waitForIdle() + assertMonthInside(month, dialog) + } + + @Test fun fallbackDialogUsesTheSameFittingGrid() { + 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) + } + + @Test fun monthGridFitsANarrow320dpColumn() { + compose.setContent { PinakesTheme { Box(Modifier.width(320.dp).testTag("narrow")) { LoanCalendar(calendar(), selected = null, onSelect = {}) } } } + val column = compose.onNodeWithTag("narrow").fetchSemanticsNode().boundsInRoot + assertMonthInside(YearMonth.now(), column) + assertTrue(compose.onAllNodesHelper("calendar-grid")) + } + + private fun androidx.compose.ui.test.junit4.ComposeContentTestRule.onAllNodesHelper(tag: String): Boolean = + onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().size == 1 +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt new file mode 100644 index 0000000..3fd59dd --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt @@ -0,0 +1,224 @@ +package com.pinakes.app.staffe2e + +import android.app.Application +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performScrollToNode +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import com.pinakes.app.MainActivity +import com.pinakes.app.R +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.HealthPayload +import com.pinakes.app.data.sync.CatalogSyncWorker +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import org.junit.After +import org.junit.Rule + +/** + * Drives the real app (MainActivity, real Hilt graph, real ViewModels, repositories and Room) + * against [FakePinakes] on the device loopback. The session is set up the way login leaves it + * (instance URL, token, role, `/health` flags), then the test acts like a user: tabs, rows, + * buttons, typed text. System UI (camera permission, airplane mode) goes through UiAutomator. + * + * Runs on a disposable emulator: it replaces the app's stored session and offline cache. + */ +abstract class AppFlowTest { + @get:Rule val compose = createEmptyComposeRule() + + protected val instrumentation get() = InstrumentationRegistry.getInstrumentation() + protected val context get() = instrumentation.targetContext + protected val device: UiDevice get() = UiDevice.getInstance(instrumentation) + protected lateinit var api: FakePinakes + private var scenario: ActivityScenario? = null + + protected val deps: CatalogSyncWorker.Deps by lazy { + EntryPointAccessors.fromApplication(context.applicationContext as Application, CatalogSyncWorker.Deps::class.java) + } + + protected fun text(id: Int, vararg args: Any): String = context.getString(id, *args) + + /** Sign in as [role] on a fresh fake instance and open the app. */ + protected fun launch(role: String = "admin", health: String = "health_staff", configure: FakePinakes.() -> Unit = {}) { + setAirplaneMode(false) + api = FakePinakes().apply(configure) + api.json("GET health", health) + runBlocking { deps.authRepository().forgetInstance() } + val session = deps.session() + session.saveInstance(api.origin, api.apiBaseUrl, "Test Library", allowInsecure = true) + session.saveToken("test-token") + session.saveRole(role) + val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + deps.featureStore().update(json.decodeFromString>(FakePinakes.fixture(health)).data!!) + scenario = ActivityScenario.launch(MainActivity::class.java) + waitFor(hasText(text(R.string.nav_profile))) + } + + @After + fun tearDown() { + setAirplaneMode(false) + scenario?.close() + if (::api.isInitialized) api.shutdown() + } + + protected fun waitFor(matcher: androidx.compose.ui.test.SemanticsMatcher, timeoutMs: Long = 10_000) { + compose.waitUntil(timeoutMs) { compose.onAllNodes(matcher, useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() } + } + + /** Like [waitFor] on the merged tree (a field's label or a button's text belong to the merged node). */ + protected fun waitForMerged(matcher: androidx.compose.ui.test.SemanticsMatcher, timeoutMs: Long = 10_000) { + compose.waitUntil(timeoutMs) { compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() } + } + + protected fun waitGone(matcher: androidx.compose.ui.test.SemanticsMatcher, timeoutMs: Long = 10_000) { + compose.waitUntil(timeoutMs) { compose.onAllNodes(matcher, useUnmergedTree = true).fetchSemanticsNodes().isEmpty() } + } + + protected fun waitText(value: String, timeoutMs: Long = 10_000) = waitFor(hasText(value), timeoutMs) + + protected fun exists(value: String, substring: Boolean = false): Boolean = + compose.onAllNodes(hasText(value, substring = substring), useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() + + protected fun node(value: String): SemanticsNodeInteraction = compose.onAllNodes(hasText(value)).onFirst() + + protected fun click(value: String) { + waitText(value) + node(value).performScrollToSafely().performClick() + compose.waitForIdle() + } + + /** + * Click [value] until [expected] shows (a tap can land while the list is still settling, + * e.g. right after the profile finished loading). Fails after [attempts]. + */ + protected fun clickUntil(value: String, expected: String, attempts: Int = 3) { + repeat(attempts) { + click(value) + if (runCatching { waitText(expected, 5_000) }.isSuccess) return + } + waitText(expected, 1) + } + + protected fun clickDescription(description: String) { + waitFor(hasContentDescription(description)) + compose.onAllNodes(hasContentDescription(description)).onFirst().performClick() + compose.waitForIdle() + } + + protected fun openTab(labelRes: Int) = click(text(labelRes)) + + protected fun SemanticsNodeInteraction.performScrollToSafely(): SemanticsNodeInteraction = + runCatching { performScrollTo() }.getOrDefault(this) + + /** Wait until a request matching [method] + [path] reached the fake server. */ + protected fun waitRequest(method: String, path: String, timeoutMs: Long = 10_000) { + compose.waitUntil(timeoutMs) { api.recorded(method, path).isNotEmpty() } + } + + /** Wait for the offline catalogue to hold the fixture rows (the first sync after launch). */ + protected fun waitCatalogueCached() { + compose.waitUntil(15_000) { runBlocking { deps.catalogRepository().hasCachedCatalog() } } + } + + /** Deny the system camera prompt if it shows (it does on the first scanner visits). */ + protected fun denyCameraPromptIfShown() { + val deny = device.wait(Until.findObject(By.res("com.android.permissioncontroller:id/permission_deny_button")), 3_000) + ?: device.wait(Until.findObject(By.res("com.android.permissioncontroller:id/permission_deny_and_dont_ask_again_button")), 500) + deny?.click() + device.waitForIdle() + } + + /** Profile → Staff area (the hub). */ + protected fun openStaffHub() { + openTab(R.string.nav_profile) + clickUntil(text(R.string.profile_action_staff), text(R.string.staff_scan_subtitle)) + } + + /** Scroll the lazy list holding [value] until it is composed (items below the fold are not in the tree). */ + protected fun scrollListTo(value: String) { + val list = compose.onAllNodes(androidx.compose.ui.test.hasScrollToNodeAction()) + compose.waitUntil(10_000) { list.fetchSemanticsNodes().isNotEmpty() } + val count = list.fetchSemanticsNodes().size + for (i in 0 until count) { + val done = runCatching { list[i].performScrollToNode(hasText(value)) }.isSuccess + if (done) return + } + } + + protected fun clickTag(tag: String) { + waitFor(androidx.compose.ui.test.hasTestTag(tag)) + compose.onAllNodes(androidx.compose.ui.test.hasTestTag(tag)).onFirst().performScrollToSafely().performClick() + compose.waitForIdle() + } + + /** Click the button labelled [value] inside the open dialog (not the identical one behind it). */ + protected fun clickInDialog(value: String) { + val matcher = hasText(value) and androidx.compose.ui.test.hasClickAction() and androidx.compose.ui.test.hasAnyAncestor(androidx.compose.ui.test.isDialog()) + waitForMerged(matcher) + compose.onAllNodes(matcher).onFirst().performClick() + compose.waitForIdle() + } + + /** Type into the text field whose label (or placeholder) is [label]. */ + protected fun typeInto(label: String, value: String) { + val matcher = androidx.compose.ui.test.hasSetTextAction() and hasText(label, substring = true) + waitForMerged(matcher) + compose.onAllNodes(matcher).onFirst().performScrollToSafely().performTextInput(value) + compose.waitForIdle() + } + + /** Type into the text field tagged [tag] (search fields without a label). */ + protected fun typeIntoTag(tag: String, value: String) { + val matcher = androidx.compose.ui.test.hasSetTextAction() and androidx.compose.ui.test.hasTestTag(tag) + waitFor(matcher) + compose.onAllNodes(matcher).onFirst().performTextInput(value) + compose.waitForIdle() + } + + /** The UTF-8 body of the last [method] [path] request. */ + protected fun lastBody(method: String, path: String): String { + waitRequest(method, path) + return api.recorded(method, path).last().body.clone().readUtf8() + } + + protected fun setAirplaneMode(on: Boolean) { + val mode = if (on) "enable" else "disable" + instrumentation.uiAutomation.executeShellCommand("cmd connectivity airplane-mode $mode").close() + Thread.sleep(1_500) + } + + protected fun publishDownload(name: String, content: String, mime: String = "text/csv"): android.net.Uri { + check(android.os.Build.VERSION.SDK_INT >= 29) + val values = android.content.ContentValues().apply { + put(android.provider.MediaStore.Downloads.DISPLAY_NAME, name) + put(android.provider.MediaStore.Downloads.MIME_TYPE, mime) + } + val resolver = context.contentResolver + val uri = resolver.insert(android.provider.MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) ?: error("MediaStore refused the download") + resolver.openOutputStream(uri)!!.use { it.write(content.toByteArray()) } + return uri + } + + /** Find [name] in the system document picker (Recent, else the Downloads root) and pick it. */ + protected fun pickInDocumentsUi(name: String) { + val file = device.wait(Until.findObject(By.text(name)), 6_000) ?: run { + device.wait(Until.findObject(By.desc("Show roots")), 3_000)?.click() + device.wait(Until.findObject(By.text("Downloads")), 3_000)?.click() + device.wait(Until.findObject(By.text(name)), 6_000) + } + org.junit.Assert.assertNotNull("$name not shown by the document picker", file) + file!!.click() + device.waitForIdle() + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/ArchivesStaffFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/ArchivesStaffFlowTest.kt new file mode 100644 index 0000000..3c2ccfb --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/ArchivesStaffFlowTest.kt @@ -0,0 +1,53 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Hub → Archives: publish an unpublished unit, and create a new one (validated before sending). */ +class ArchivesStaffFlowTest : AppFlowTest() { + + private fun open(configure: FakePinakes.() -> Unit) { + launch { + json("GET staff/archives", "archives_staff") + json("GET staff/archives/lookups", "archive_lookups") + json("GET staff/archives/3", "archive_3") + configure() + } + openStaffHub() + click(text(R.string.staff_archives)) + waitText("Fondo Rossi") + } + + @Test fun publishAnUnpublishedUnitKeepsEveryOtherField() { + open { json("PUT staff/archives/3", "archive_3_published") } + click("Fondo Rossi") + waitText("IT-PIN-1") + click(text(R.string.staff_archive_published)) + clickTag("archive-save") + val body = lastBody("PUT", "staff/archives/3") + assertTrue(body, body.contains("\"published\":true")) + assertTrue(body, body.contains("\"reference_code\":\"IT-PIN-1\"")) + assertTrue(body, body.contains("\"date_start\":1920")) + assertTrue(body, body.contains("\"extent\":\"3 buste\"")) + waitText(text(R.string.staff_archive_saved)) + } + + @Test fun createAUnitAfterTheRequiredFields() { + open { json("POST staff/archives", "archive_10", status = 201) } + clickDescription(text(R.string.staff_archive_new)) + clickTag("archive-save") + waitText(text(R.string.staff_form_fix_errors)) + assertTrue(api.recorded("POST", "staff/archives").isEmpty()) + typeInto(text(R.string.staff_archive_reference_code), "IT-PIN-9") + typeInto(text(R.string.staff_archive_constructed_title), "Diari") + clickTag("archive-save") + val body = lastBody("POST", "staff/archives") + assertTrue(body, body.contains("\"reference_code\":\"IT-PIN-9\"")) + assertTrue(body, body.contains("\"institution_code\":\"PINAKES\"")) + assertTrue(body, body.contains("\"date_start\":null")) + // Saved: the unit now takes authorities and files. + waitText(text(R.string.staff_archive_created)) + waitText(text(R.string.staff_archive_attach_authority)) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/BookFormFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/BookFormFlowTest.kt new file mode 100644 index 0000000..4d19018 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/BookFormFlowTest.kt @@ -0,0 +1,61 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextReplacement +import com.pinakes.app.R +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The manual record form: required title, complete locations, true patches on edit. */ +class BookFormFlowTest : AppFlowTest() { + + private fun openHub() { + openTab(R.string.nav_profile) + clickUntil(text(R.string.profile_action_staff), text(R.string.staff_scan_subtitle)) + } + + private fun field(label: String) = compose.onAllNodes(hasSetTextAction() and hasText(label, substring = true)).onFirst() + + @Test fun titleIsRequiredAndNothingIsSent() { + launch() + openHub() + click(text(R.string.staff_add_manual)) + waitText(text(R.string.staff_book_save)) + click(text(R.string.staff_book_save)) + waitText(text(R.string.staff_field_required)) + assertTrue(api.recorded("POST", "staff/books").isEmpty()) + } + + @Test fun shelfWithoutLevelShowsTheErrorAndSendsNothing() { + launch() + openHub() + click(text(R.string.staff_add_manual)) + waitText(text(R.string.staff_field_title)) + field(text(R.string.staff_field_title)).performTextInput("Spartito senza ISBN") + click(text(R.string.staff_shelf)) + click("A · Narrativa") + click(text(R.string.staff_book_save)) + waitText(text(R.string.staff_level_required)) + assertTrue(api.recorded("POST", "staff/books").isEmpty()) + } + + @Test fun editingOneFieldSendsOnlyThatKey() { + launch { json("PUT staff/books/5", "staff_book_5") } + openHub() + click(text(R.string.staff_books)) + click("Orlando") + waitText("A Biography") + compose.onAllNodes(hasSetTextAction() and hasText("A Biography")).onFirst().performTextReplacement("A new subtitle") + click(text(R.string.staff_book_save)) + waitRequest("PUT", "staff/books/5") + val body = Json.parseToJsonElement(api.recorded("PUT", "staff/books/5").single().body.readUtf8()).jsonObject + assertEquals(setOf("subtitle"), body.keys) + assertEquals("\"A new subtitle\"", body["subtitle"].toString()) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/CalendarFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/CalendarFlowTest.kt new file mode 100644 index 0000000..1781fb9 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/CalendarFlowTest.kt @@ -0,0 +1,50 @@ +package com.pinakes.app.staffe2e + +import android.content.ClipDescription +import android.content.ClipboardManager +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Profile → loans calendar: create, masked link, sensitive copy, confirmed regenerate/turn off. */ +class CalendarFlowTest : AppFlowTest() { + + private val token = "0123456789abcdef".repeat(4) + + @Test fun createCopyRegenerateAndTurnOff() { + launch { json("POST me/calendar/token", "calendar_enabled", 201) } + openTab(R.string.nav_profile) + clickUntil(text(R.string.profile_action_calendar), text(R.string.calendar_enable)) + click(text(R.string.calendar_enable)) + waitRequest("POST", "me/calendar/token") + + // Only host and tail of the secret are on screen. + waitText("localhost/…abcdef.ics") + assertFalse(exists(token, substring = true)) + + click(text(R.string.calendar_copy_link)) + var sensitive = false + var copied: String? = null + instrumentation.runOnMainSync { + val clip = context.getSystemService(ClipboardManager::class.java).primaryClip + copied = clip?.getItemAt(0)?.text?.toString() + sensitive = clip?.description?.extras?.getBoolean(ClipDescription.EXTRA_IS_SENSITIVE) == true + } + assertTrue("clip not flagged sensitive", sensitive) + assertEquals("http://localhost/api/v1/calendar/feed/$token.ics", copied) + + // Regenerate asks first; cancelling sends nothing. + click(text(R.string.calendar_regenerate)) + waitText(text(R.string.calendar_regenerate_confirm)) + click(text(R.string.action_cancel)) + assertEquals(1, api.recorded("POST", "me/calendar/token").size) + + // Turning it off asks too. + click(text(R.string.calendar_disable)) + waitText(text(R.string.calendar_disable_confirm)) + click(text(R.string.action_cancel)) + assertTrue(api.recorded("DELETE", "me/calendar/token").isEmpty()) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/CirculationFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/CirculationFlowTest.kt new file mode 100644 index 0000000..254d2a7 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/CirculationFlowTest.kt @@ -0,0 +1,77 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Hub → Circulation desk → counters → On loan → a loan → Return as damaged with a penalty. */ +class CirculationFlowTest : AppFlowTest() { + + private fun desk() = launch { + json("GET staff/circulation/summary", "circulation_summary") + json("GET staff/loans", "staff_loans_active") + json("GET staff/loans/7", "staff_loan_7") + json("POST staff/loans/7/return", "staff_loan_7_returned") + json("GET staff/users/3", "staff_user_3") + } + + @Test fun returnALoanAsDamagedWithAPenalty() { + desk() + openStaffHub() + click(text(R.string.staff_circulation_desk)) + waitText(text(R.string.staff_circulation_today, 1, 2)) + clickTag("counter-active") + waitRequest("GET", "staff/loans") + assertEquals("active", api.recorded("GET", "staff/loans").last().requestUrl?.queryParameter("bucket")) + click("Orlando") + waitText("INV-0011") + clickTag("action-return") + waitText(text(R.string.staff_return_title)) + click(text(R.string.staff_return_outcome_damaged)) + typeInto(text(R.string.staff_penalty), "12,50") + clickTag("return-confirm") + val body = lastBody("POST", "staff/loans/7/return") + assertTrue(body, body.contains("\"outcome\":\"danneggiato\"")) + assertTrue(body, body.contains("\"penalty\":12.5")) + waitText(text(R.string.staff_loan_returned)) + } + + @Test fun anInvalidPenaltyIsNotSent() { + desk() + openStaffHub() + click(text(R.string.staff_circulation_desk)) + clickTag("counter-active") + click("Orlando") + clickTag("action-return") + click(text(R.string.staff_return_outcome_lost)) + typeInto(text(R.string.staff_penalty), "12.345") + waitText(text(R.string.staff_penalty_invalid)) + clickTag("return-confirm") + compose.waitForIdle() + assertTrue(api.recorded("POST", "staff/loans/7/return").isEmpty()) + } + + @Test fun theBorrowerOpensFromTheLoan() { + desk() + openStaffHub() + click(text(R.string.staff_circulation_desk)) + clickTag("counter-active") + click("Orlando") + click(text(R.string.staff_user_open)) + waitText("C-0012") + waitText(text(R.string.staff_user_open_loans).uppercase()) + } + + @Test fun aServerConflictShowsTheServerMessage() { + desk() + api.body("POST staff/loans/7/renew", 409, """{"data":null,"meta":{},"error":{"code":"max_renewals","message":"x"}}""") + openStaffHub() + click(text(R.string.staff_circulation_desk)) + clickTag("counter-active") + click("Orlando") + clickTag("action-renew") + clickInDialog(text(R.string.staff_loan_action_renew)) + waitText(text(R.string.staff_err_max_renewals)) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/ConnectivityCollectorTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/ConnectivityCollectorTest.kt new file mode 100644 index 0000000..cf154a7 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/ConnectivityCollectorTest.kt @@ -0,0 +1,31 @@ +package com.pinakes.app.staffe2e + +import android.app.Application +import androidx.test.platform.app.InstrumentationRegistry +import com.pinakes.app.data.connectivity.ConnectivityObserver +import com.pinakes.app.data.sync.CatalogSyncWorker +import com.pinakes.app.ui.screens.search.BookSort +import com.pinakes.app.ui.screens.search.SearchViewModel +import dagger.hilt.android.EntryPointAccessors +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Search listens to connectivity once, however many searches, sorts and filter resets run. */ +class ConnectivityCollectorTest { + + @Test fun searchKeepsASingleConnectivityCollector() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val deps = EntryPointAccessors.fromApplication(instrumentation.targetContext.applicationContext as Application, CatalogSyncWorker.Deps::class.java) + val connectivity = ConnectivityObserver.Fixed(true) + lateinit var vm: SearchViewModel + instrumentation.runOnMainSync { vm = SearchViewModel(deps.catalogRepository(), connectivity) } + instrumentation.runOnMainSync { + repeat(3) { vm.submitSearch() } + vm.setSort(BookSort.TITLE_ASC) + vm.setSort(BookSort.TITLE_DESC) + vm.clearFilters() + } + instrumentation.waitForIdleSync() + assertEquals(1, connectivity.subscribers) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/CopiesFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/CopiesFlowTest.kt new file mode 100644 index 0000000..b12d94a --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/CopiesFlowTest.kt @@ -0,0 +1,69 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performTextReplacement +import com.pinakes.app.R +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Book editor → copies: edit a copy's state and notes, a refused delete, a copy with its own code. */ +class CopiesFlowTest : AppFlowTest() { + + private fun openBook(configure: FakePinakes.() -> Unit = {}) { + launch { + json("GET staff/books/5", "staff_book_5_copies") + configure() + } + openStaffHub() + click(text(R.string.staff_books)) + click("Orlando") + waitText("INV-0011") + } + + @Test fun editACopyStateAndNotes() { + openBook { json("PUT staff/copies/11", "staff_copy_11") } + click("INV-0011") + click(text(R.string.staff_copy_status)) + click("In manutenzione") + compose.onAllNodes(hasSetTextAction() and androidx.compose.ui.test.hasTestTag("copy-notes")).onFirst().performTextReplacement("Cover loose") + clickTag("copy-save") + val body = lastBody("PUT", "staff/copies/11") + assertTrue(body, body.contains("\"status\":\"manutenzione\"")) + assertTrue(body, body.contains("\"notes\":\"Cover loose\"")) + waitText(text(R.string.staff_copy_saved)) + } + + @Test fun aCopyWithHistoryCannotBeDeleted() { + openBook { + body("DELETE staff/copies/12", 409, """{"data":null,"meta":{"reason":"copy_has_history"},"error":{"code":"copy_has_history","message":"x"}}""") + } + click("INV-0012") + click(text(R.string.staff_copy_delete)) + clickInDialog(text(R.string.staff_copy_delete)) + waitRequest("DELETE", "staff/copies/12") + waitText(text(R.string.staff_err_copy_has_history)) + } + + @Test fun addACopyWithAnExplicitCode() { + openBook { json("POST staff/books/5/copies", "copies_added_code", status = 201) } + click(text(R.string.staff_copy_add_with_code)) + typeInto(text(R.string.staff_copy_code), "LIB-7") + clickTag("copy-save") + val body = lastBody("POST", "staff/books/5/copies") + assertTrue(body, body.contains("\"quantity\":1")) + assertTrue(body, body.contains("\"inventory_number\":\"LIB-7\"")) + waitText(text(R.string.staff_copy_added)) + } + + @Test fun aTakenCodeIsReported() { + openBook { + body("POST staff/books/5/copies", 409, """{"data":null,"meta":{"existing":{"id":9,"inventory_number":"LIB-7","book_id":2}},"error":{"code":"duplicate","message":"x"}}""") + } + click(text(R.string.staff_copy_add_with_code)) + typeInto(text(R.string.staff_copy_code), "LIB-7") + clickTag("copy-save") + waitText(text(R.string.staff_copy_code_taken, "LIB-7")) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/CoversFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/CoversFlowTest.kt new file mode 100644 index 0000000..15b34a5 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/CoversFlowTest.kt @@ -0,0 +1,31 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Covers & enrichment: the per-book loop over the missing covers, and the automatic enrichment switch. */ +class CoversFlowTest : AppFlowTest() { + + @Test fun fetchMissingCoversOneByOneAndTurnOnEnrichment() { + launch { + json("GET staff/covers/missing", "missing_covers") + json("GET staff/enrich", "enrich_overview") + json("POST staff/books/5/cover/fetch", "cover_fetched") + json("POST staff/books/6/cover/fetch", "cover_fetched") + json("PUT staff/enrich/cron", "enrich_cron_on") + } + openStaffHub() + click(text(R.string.staff_covers)) + waitText(text(R.string.staff_covers_missing_count, 2)) + clickTag("covers-loop") + waitText(text(R.string.staff_covers_loop_progress, 2, 2, 2, 0), timeoutMs = 15_000) + assertEquals(1, api.recorded("POST", "staff/books/5/cover/fetch").size) + assertEquals(1, api.recorded("POST", "staff/books/6/cover/fetch").size) + // Never the long server-side jobs without a confirmation. + assertTrue(api.recorded("POST", "staff/covers/sync").isEmpty()) + click(text(R.string.staff_enrich_cron)) + assertEquals("""{"enabled":true}""", lastBody("PUT", "staff/enrich/cron")) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/DesiderataStaffFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/DesiderataStaffFlowTest.kt new file mode 100644 index 0000000..d9b92b5 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/DesiderataStaffFlowTest.kt @@ -0,0 +1,46 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Hub → Donation offers: receive an offer for a wanted title, receive one into a picked record, a closed offer. */ +class DesiderataStaffFlowTest : AppFlowTest() { + + private fun open(configure: FakePinakes.() -> Unit) { + launch { + json("GET staff/desiderata/offers", "offers_pending") + configure() + } + openStaffHub() + click(text(R.string.staff_desiderata)) + waitText("Un libro qualunque") + } + + @Test fun receiveAnOfferForAWantedTitle() { + open { json("POST staff/desiderata/offers/41/receive", "offer_41_received") } + clickTag("offer-41") + clickTag("offer-receive") + assertEquals("{}", lastBody("POST", "staff/desiderata/offers/41/receive")) + waitText(text(R.string.staff_offer_received)) + } + + @Test fun anOfferWithoutATitleIsReceivedIntoAPickedRecord() { + open { json("POST staff/desiderata/offers/42/receive", "offer_42_received") } + clickTag("offer-42") + clickTag("offer-receive") + waitText(text(R.string.staff_offer_receive_into)) + clickTag("pick-5") + assertEquals("""{"received_book_id":5}""", lastBody("POST", "staff/desiderata/offers/42/receive")) + waitText(text(R.string.staff_offer_received)) + } + + @Test fun aClosedOfferSaysSo() { + open { + body("POST staff/desiderata/offers/41/accept", 409, """{"data":null,"meta":{},"error":{"code":"offer_closed","message":"x"}}""") + } + clickTag("offer-41") + click(text(R.string.staff_offer_accept)) + waitText(text(R.string.staff_err_offer_closed)) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/DigitalUploadFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/DigitalUploadFlowTest.kt new file mode 100644 index 0000000..17557a6 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/DigitalUploadFlowTest.kt @@ -0,0 +1,40 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Book editor → Digital content: an eBook picked in the system picker is streamed to the server. */ +class DigitalUploadFlowTest : AppFlowTest() { + + @Test fun uploadAnEbookFromTheBookEditor() { + val name = "e2e-ebook-${System.currentTimeMillis() % 100000}.pdf" + val uri = publishDownload(name, "%PDF-1.4\n% e2e\n", "application/pdf") + try { + launch(health = "health_staff_digital") { json("POST staff/books/5/digital", "digital_uploaded") } + openStaffHub() + click(text(R.string.staff_books)) + click("Orlando") + click(text(R.string.staff_digital_ebook)) + pickInDocumentsUi(name) + waitRequest("POST", "staff/books/5/digital", timeoutMs = 20_000) + val body = api.recorded("POST", "staff/books/5/digital").last().body.clone().readUtf8() + assertTrue(body, body.contains("filename=\"$name\"")) + assertTrue(body, body.contains("name=\"digital_type\"") && body.contains("ebook")) + assertTrue(body, body.contains("%PDF-1.4")) + waitText(text(R.string.staff_digital_ebook_done)) + } finally { + context.contentResolver.delete(uri, null, null) + } + } + + @Test fun withoutTheFlagThereIsNoDigitalSection() { + launch() + openStaffHub() + click(text(R.string.staff_books)) + click("Orlando") + waitText(text(R.string.staff_book_save)) + assertFalse(exists(text(R.string.staff_digital_ebook))) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/FakePinakes.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/FakePinakes.kt new file mode 100644 index 0000000..bee02aa --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/FakePinakes.kt @@ -0,0 +1,90 @@ +package com.pinakes.app.staffe2e + +import androidx.test.platform.app.InstrumentationRegistry +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList + +/** + * A fake Pinakes Mobile API on the device's loopback: every route answers a JSON fixture from + * `androidTest/assets/fixtures`, unknown routes answer the API's 404 envelope, and every + * request is recorded so a test can assert what the app actually sent. + * + * Routes are keyed `"METHOD /path"` with the path relative to `/api/v1/` and without the + * query string, e.g. `"GET staff/lookups"`. + */ +class FakePinakes { + val server = MockWebServer() + private val routes = ConcurrentHashMap MockResponse>() + val requests = CopyOnWriteArrayList() + + init { + // Everything the app asks for in the background, so screens never block on a 404. + json("GET health", "health_staff") + json("GET me", "me_admin") + json("GET me/devices", "empty_list") + json("GET me/notifications", "empty_list") + json("GET me/loans", "loans_empty") + json("GET me/reservations", "empty_list") + json("GET me/wishlist", "empty_list") + json("GET me/calendar", "calendar_disabled") + json("GET catalog/search", "search_books") + json("GET catalog/sync", "sync_full") + json("GET catalog/genres", "empty_list") + json("GET catalog/languages", "empty_list") + json("GET catalog/books/1", "book_1") + json("GET staff/lookups", "staff_lookups") + json("GET staff/genres", "staff_genres") + json("GET staff/shelves", "staff_shelves") + json("GET staff/books", "staff_books") + json("GET staff/books/5", "staff_book_5") + json("GET staff/authors", "empty_list") + json("GET staff/publishers", "empty_list") + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + requests += request + val path = request.requestUrl?.encodedPath.orEmpty().removePrefix("/api/v1/").trimEnd('/') + val route = routes["${request.method} $path"] + return route?.invoke() ?: notFound() + } + } + server.start() + } + + /** Base API URL the app is pointed at (`http://localhost:port/api/v1/`). */ + val apiBaseUrl: String get() = server.url("/api/v1/").toString() + val origin: String get() = apiBaseUrl.removeSuffix("/api/v1/") + + fun json(route: String, fixture: String, status: Int = 200) { + routes[route] = { MockResponse().setResponseCode(status).setHeader("Content-Type", "application/json").setBody(fixture(fixture)) } + } + + /** Answer [route] with each fixture in turn (status, fixture), repeating the last one. */ + fun sequence(route: String, vararg steps: Pair) { + val calls = java.util.concurrent.atomic.AtomicInteger(0) + routes[route] = { + val (status, name) = steps[calls.getAndIncrement().coerceAtMost(steps.size - 1)] + MockResponse().setResponseCode(status).setHeader("Content-Type", "application/json").setBody(fixture(name)) + } + } + + fun body(route: String, status: Int, body: String) { + routes[route] = { MockResponse().setResponseCode(status).setHeader("Content-Type", "application/json").setBody(body) } + } + + fun recorded(method: String, path: String): List = + requests.filter { it.method == method && it.requestUrl?.encodedPath == "/api/v1/$path" } + + fun shutdown() = runCatching { server.shutdown() } + + private fun notFound() = MockResponse().setResponseCode(404).setHeader("Content-Type", "application/json") + .setBody("""{"data":null,"meta":{},"error":{"code":"not_found","message":"Not found."}}""") + + companion object { + fun fixture(name: String): String = + InstrumentationRegistry.getInstrumentation().context.assets.open("fixtures/$name.json").bufferedReader().use { it.readText() } + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/ImportFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/ImportFlowTest.kt new file mode 100644 index 0000000..2c1534d --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/ImportFlowTest.kt @@ -0,0 +1,64 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Imports: chunks requested in order from the server's progress, and an upload through the system picker. */ +class ImportFlowTest : AppFlowTest() { + + private val jobRoutes: FakePinakes.() -> Unit = { + json("GET staff/imports", "imports_overview") + json("GET staff/imports/6f1c2a", "import_job_created") + // 0 → 10, then the server says it is already at 20 (another device moved on), then 20 → 30. + sequence( + "POST staff/imports/6f1c2a/chunk", + 200 to "import_job_chunk_10", + 409 to "import_out_of_order_20", + 200 to "import_job_chunk_30", + ) + } + + private fun chunkStarts(): List = api.recorded("POST", "staff/imports/6f1c2a/chunk").map { it.body.clone().readUtf8() } + + @Test fun anOpenJobResumesAndFollowsTheServersProgress() { + launch(configure = jobRoutes) + openStaffHub() + click(text(R.string.staff_imports)) + scrollListTo("libri.csv") + click("libri.csv") + waitText(text(R.string.staff_import_status_done), timeoutMs = 20_000) + assertEquals(listOf("""{"start":0}""", """{"start":10}""", """{"start":20}"""), chunkStarts()) + waitText(text(R.string.staff_import_progress, 30, 30)) + waitText("Row 12: missing title") + waitText("Row 25: bad ISBN") + } + + @Test fun uploadAPickedCsvThenRunTheChunks() { + val name = "e2e-import-${System.currentTimeMillis() % 100000}.csv" + val uri = publishDownload(name, "titolo;autore\nOrlando;Virginia Woolf\nIl barone rampante;Italo Calvino\n") + try { + launch { + jobRoutes() + json("POST staff/imports", "import_job_created", status = 201) + } + openStaffHub() + click(text(R.string.staff_imports)) + click(text(R.string.staff_import_choose_file)) + pickInDocumentsUi(name) + waitText(name, timeoutMs = 15_000) + clickTag("import-start") + waitRequest("POST", "staff/imports") + val body = api.recorded("POST", "staff/imports").last().body.clone().readUtf8() + assertTrue(body, body.contains("filename=\"$name\"")) + assertTrue(body, body.contains("Il barone rampante;Italo Calvino")) + assertTrue(body, body.contains("name=\"kind\"") && body.contains("csv")) + assertTrue(body, body.contains("name=\"enable_scraping\"")) + waitText(text(R.string.staff_import_status_done), timeoutMs = 20_000) + assertEquals(3, chunkStarts().size) + } finally { + context.contentResolver.delete(uri, null, null) + } + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/LoanCreateFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/LoanCreateFlowTest.kt new file mode 100644 index 0000000..d328842 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/LoanCreateFlowTest.kt @@ -0,0 +1,63 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Hub → New loan: search the borrower, pick the book, hand over now, then the loan opens. */ +class LoanCreateFlowTest : AppFlowTest() { + + private fun setUp() = launch { + json("GET staff/users", "staff_users") + json("GET staff/books/5/availability", "staff_book_availability") + json("POST staff/loans", "staff_loan_created", status = 201) + json("GET staff/loans/21", "staff_loan_created") + } + + private fun fillForm() { + openStaffHub() + click(text(R.string.staff_loan_new)) + click(text(R.string.staff_pick_user_placeholder)) + typeIntoTag("picker-search", "Ada") + click("Ada Rossi") + click(text(R.string.staff_pick_book_placeholder)) + typeIntoTag("picker-search", "Orl") + click("Orlando") + waitRequest("GET", "staff/books/5/availability") + } + + @Test fun createAnImmediateLoanSendsTheFormAndAnIdempotencyKey() { + setUp() + fillForm() + assertEquals("3", api.recorded("GET", "staff/books/5/availability").last().requestUrl?.queryParameter("user_id")) + click(text(R.string.staff_loan_create)) + waitRequest("POST", "staff/loans") + val request = api.recorded("POST", "staff/loans").last() + val body = request.body.clone().readUtf8() + assertTrue(body, body.contains("\"user_id\":3")) + assertTrue(body, body.contains("\"book_id\":5")) + assertTrue(body, body.contains("\"immediate\":true")) + val key = request.getHeader("Idempotency-Key") + assertEquals(36, key?.length) + // The created loan opens. + waitRequest("GET", "staff/loans/21") + waitText("INV-0011") + } + + @Test fun anIneligibleBorrowerIsFlaggedAndTheServerRefusalShown() { + setUp() + api.body("POST staff/loans", 409, """{"data":null,"meta":{"reason":"user_suspended"},"error":{"code":"not_eligible","message":"x"}}""") + openStaffHub() + click(text(R.string.staff_loan_new)) + click(text(R.string.staff_pick_user_placeholder)) + typeIntoTag("picker-search", "Bru") + waitText("Suspended") + click("Bruno Sospeso") + click(text(R.string.staff_pick_book_placeholder)) + typeIntoTag("picker-search", "Orl") + click("Orlando") + click(text(R.string.staff_loan_create)) + waitText(text(R.string.staff_err_not_eligible)) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/MergeFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/MergeFlowTest.kt new file mode 100644 index 0000000..22cdba2 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/MergeFlowTest.kt @@ -0,0 +1,37 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Hub → Merge duplicates: a suggested author group merged into the record with most books. */ +class MergeFlowTest : AppFlowTest() { + + @Test fun mergeASuggestedDuplicateGroup() { + launch { + json("GET staff/authors/duplicates", "staff_author_duplicates") + json("POST staff/authors/merge", "merge_result") + } + openStaffHub() + click(text(R.string.staff_merges)) + waitText(text(R.string.staff_merge_author_books, "Woolf, Virginia", 1)) + clickTag("dup-group-0") + clickTag("merge-confirm") + val body = lastBody("POST", "staff/authors/merge") + assertTrue(body, body.contains("\"ids\":[3,8]")) + assertTrue(body, body.contains("\"primary_id\":3")) + waitText(text(R.string.staff_merge_done, 2)) + } + + @Test fun aRetriedMergeWhoseRecordsAreGoneCountsAsDone() { + launch { + json("GET staff/authors/duplicates", "staff_author_duplicates") + body("POST staff/authors/merge", 409, """{"data":null,"meta":{"missing":[8]},"error":{"code":"merge_error","message":"x"}}""") + } + openStaffHub() + click(text(R.string.staff_merges)) + clickTag("dup-group-0") + clickTag("merge-confirm") + waitText(text(R.string.staff_merge_already_done)) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/OfflineFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/OfflineFlowTest.kt new file mode 100644 index 0000000..beacdc8 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/OfflineFlowTest.kt @@ -0,0 +1,74 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.hasText +import com.pinakes.app.R +import com.pinakes.app.ui.common.DateFormat +import org.junit.Assert.assertFalse +import org.junit.Test + +/** + * Offline behaviour: cached answers with a banner, no loan affordances, no availability shown as + * current, localized empty states and never the raw network exception. + */ +class OfflineFlowTest : AppFlowTest() { + + /** Server gone and airplane mode on: the device is really offline. */ + private fun goOffline() { + api.shutdown() + setAirplaneMode(true) + } + + private fun assertNoRawNetworkError() { + assertFalse(exists("Failed to connect", substring = true)) + assertFalse(exists("Unable to resolve", substring = true)) + } + + @Test fun offlineSearchShowsCachedResultsWithoutAvailabilityOrLoanActions() { + launch() + waitCatalogueCached() + goOffline() + openTab(R.string.nav_catalog) + waitText(text(R.string.offline_results)) + waitText("Il barone rampante") + assertFalse(exists(text(R.string.availability_available))) + assertFalse(exists(text(R.string.availability_on_loan))) + assertNoRawNetworkError() + } + + @Test fun offlineDetailSaysLastKnownAndHasNoLoanButtons() { + launch() + waitCatalogueCached() + goOffline() + openTab(R.string.nav_catalog) + click("Il barone rampante") + waitText(text(R.string.offline_availability_last_known, DateFormat.dateTime("2026-10-13 10:00:00"), 1, 2)) + assertFalse(exists(text(R.string.book_request_loan))) + assertFalse(exists(text(R.string.book_reserve))) + assertNoRawNetworkError() + } + + @Test fun libraryAndWishlistWithoutASavedCopyShowTheOfflineEmptyState() { + launch() + goOffline() + openTab(R.string.nav_library) + waitText(text(R.string.offline_no_copy_title)) + assertNoRawNetworkError() + openTab(R.string.nav_wishlist) + waitText(text(R.string.offline_no_copy_title)) + assertNoRawNetworkError() + } + + @Test fun goingOfflineAfterAnOnlineLoadHidesLoanAffordances() { + launch() + openTab(R.string.nav_catalog) + click("Il barone rampante") + waitText(text(R.string.book_request_loan)) + setAirplaneMode(true) + waitGone(hasText(text(R.string.book_request_loan))) + waitText(text(R.string.offline_actions_unavailable)) + assertFalse(exists(text(R.string.availability_available))) + // Back online: the page reloads with its loan action. + setAirplaneMode(false) + waitText(text(R.string.book_request_loan), 20_000) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/ProfileFieldsFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/ProfileFieldsFlowTest.kt new file mode 100644 index 0000000..114088a --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/ProfileFieldsFlowTest.kt @@ -0,0 +1,66 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Gender and tax code follow the library's "visible fields" setting. */ +class ProfileFieldsFlowTest : AppFlowTest() { + + private fun openProfileEditor() { + openTab(R.string.nav_profile) + clickUntil(text(R.string.profile_action_edit), text(R.string.profile_edit_title)) + waitText(text(R.string.profile_first_name)) + } + + @Test fun hiddenFieldsAreAbsentFromTheProfileEditor() { + launch { + json("GET me", "me_fields_hidden") + json("GET auth/registration-fields", "registration_fields_hidden") + } + openProfileEditor() + assertFalse(exists(text(R.string.profile_tax_code))) + assertFalse(exists(text(R.string.profile_gender))) + // The birth date (never hideable) is still there. + assertTrue(exists(text(R.string.profile_birth_date))) + } + + @Test fun visibleFieldsArePresentInTheProfileEditor() { + launch { + json("GET me", "me_fields_visible") + json("GET auth/registration-fields", "registration_fields_visible") + } + openProfileEditor() + waitText(text(R.string.profile_tax_code)) + waitText(text(R.string.profile_gender)) + } + + @Test fun registrationNeverAsksForHiddenFields() { + launch(health = "health_registration_hidden") { json("GET auth/registration-fields", "registration_fields_hidden") } + // Sign out to reach the login screen, then open the registration form. + deps.session().clearToken() + clickUntil(text(R.string.login_create_account), text(R.string.register_title)) + assertFalse(exists(text(R.string.profile_tax_code))) + assertFalse(exists(text(R.string.profile_gender))) + } + + @Test fun birthDatePickerMonthFitsThePhoneScreen() { + launch { json("GET me", "me_fields_visible") } + openProfileEditor() + click("1980-01-01") + waitText(text(R.string.action_save)) + val width = context.resources.displayMetrics.widthPixels.toFloat() + // Material's day cells carry the full date ("…January 15, 1980") as their text. + compose.waitUntil(10_000) { + compose.onAllNodes(androidx.compose.ui.test.hasText("1980", substring = true) and androidx.compose.ui.test.hasClickAction()) + .fetchSemanticsNodes().size >= 28 + } + val days = compose.onAllNodes(androidx.compose.ui.test.hasText("1980", substring = true) and androidx.compose.ui.test.hasClickAction()) + .fetchSemanticsNodes() + days.forEach { n -> + val b = n.boundsInWindow + assertTrue("day cell outside the screen: $b", b.left >= 0f && b.right <= width && b.width > 0f) + } + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/ReservationsFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/ReservationsFlowTest.kt new file mode 100644 index 0000000..a541aa7 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/ReservationsFlowTest.kt @@ -0,0 +1,53 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Hub → Reservations: the queue by title, cancel with a reason, and the new-reservation form. */ +class ReservationsFlowTest : AppFlowTest() { + + @Test fun cancelAReservationWithAReason() { + launch { + json("GET staff/reservations", "staff_reservations") + json("POST staff/reservations/31/cancel", "staff_reservation_31_cancelled") + } + openStaffHub() + click(text(R.string.staff_reservations)) + waitText("Orlando") + waitFor(androidx.compose.ui.test.hasText(text(R.string.staff_queue_position, 2), substring = true)) + clickTag("reservation-31") + click(text(R.string.staff_reservation_cancel)) + typeInto(text(R.string.staff_reason_optional), "Requested by phone") + clickInDialog(text(R.string.staff_reservation_cancel)) + val body = lastBody("POST", "staff/reservations/31/cancel") + assertTrue(body, body.contains("Requested by phone")) + waitText(text(R.string.staff_reservation_cancelled)) + } + + @Test fun newReservationPostsUserAndBookWithAKey() { + launch { + json("GET staff/reservations", "staff_reservations") + json("GET staff/users", "staff_users") + json("GET staff/books/5/availability", "staff_book_availability") + json("POST staff/reservations", "staff_reservation_31_cancelled", status = 201) + } + openStaffHub() + click(text(R.string.staff_reservations)) + clickDescription(text(R.string.staff_reservation_new)) + click(text(R.string.staff_pick_user_placeholder)) + typeIntoTag("picker-search", "Ada") + click("Ada Rossi") + click(text(R.string.staff_pick_book_placeholder)) + typeIntoTag("picker-search", "Orl") + click("Orlando") + click(text(R.string.staff_reservation_create)) + waitRequest("POST", "staff/reservations") + val request = api.recorded("POST", "staff/reservations").last() + val body = request.body.clone().readUtf8() + assertTrue(body, body.contains("\"user_id\":3") && body.contains("\"book_id\":5")) + assertTrue(request.getHeader("Idempotency-Key").orEmpty().length == 36) + // Back on the queue. + waitText(text(R.string.staff_reservations_active)) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/ReturnByCodeFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/ReturnByCodeFlowTest.kt new file mode 100644 index 0000000..9b92efb --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/ReturnByCodeFlowTest.kt @@ -0,0 +1,42 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Hub → Scan a copy → type the inventory code → the copy and its loan → Return. */ +class ReturnByCodeFlowTest : AppFlowTest() { + + @Test fun typedCopyCodeFindsTheLoanAndReturnsIt() { + launch { + json("GET staff/copies/by-code", "copy_by_code_found") + json("POST staff/loans/return-by-code", "staff_loan_7_returned") + } + openStaffHub() + click(text(R.string.staff_scan_copy)) + denyCameraPromptIfShown() + typeInto(text(R.string.staff_copy_code), " INV-0011 ") + clickDescription(text(R.string.staff_scan_lookup)) + waitRequest("GET", "staff/copies/by-code") + assertEquals("INV-0011", api.recorded("GET", "staff/copies/by-code").last().requestUrl?.queryParameter("code")) + waitText("Ada Rossi · C-0012") + clickTag("copy-return") + clickTag("return-confirm") + val body = lastBody("POST", "staff/loans/return-by-code") + assertTrue(body, body.contains("\"code\":\"INV-0011\"")) + assertTrue(body, body.contains("\"outcome\":\"restituito\"")) + waitText(text(R.string.staff_copy_returned_count, 1)) + } + + @Test fun anUnknownCodeSaysSo() { + launch { json("GET staff/copies/by-code", "copy_by_code_missing") } + openStaffHub() + click(text(R.string.staff_scan_copy)) + denyCameraPromptIfShown() + typeInto(text(R.string.staff_copy_code), "NOPE-1") + clickDescription(text(R.string.staff_scan_lookup)) + waitText(text(R.string.staff_copy_not_found)) + waitText(text(R.string.staff_copy_not_found_body, "NOPE-1")) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/ReviewFixesFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/ReviewFixesFlowTest.kt new file mode 100644 index 0000000..610619e --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/ReviewFixesFlowTest.kt @@ -0,0 +1,99 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** Regressions from the phase 2 review: batching, paging, failed loads, ended jobs, exports on sign-out. */ +class ReviewFixesFlowTest : AppFlowTest() { + + @Test fun bulkRecallOfSixtyLoansGoesInTwoBatches() { + launch { + json("GET staff/circulation/summary", "circulation_summary") + json("GET staff/loans", "staff_loans_overdue_60") + sequence("POST staff/loans/bulk-recall", 200 to "bulk_recall_50", 200 to "bulk_recall_10") + } + openStaffHub() + click(text(R.string.staff_circulation_desk)) + clickTag("counter-overdue") + waitText("Overdue 00") + clickDescription(text(R.string.staff_select_many)) + click(text(R.string.staff_select_all)) + waitText(text(R.string.staff_selected_count, 60)) + click(text(R.string.staff_loan_action_recall)) + compose.waitUntil(10_000) { api.recorded("POST", "staff/loans/bulk-recall").size == 2 } + val sizes = api.recorded("POST", "staff/loans/bulk-recall").map { r -> + Regex("""\[(.*)]""").find(r.body.clone().readUtf8())!!.groupValues[1].split(',').size + } + assertEquals(listOf(50, 10), sizes) + waitText(text(R.string.staff_bulk_recalled, 59, 1)) + } + + @Test fun reservationsLoadTheNextPage() { + launch { + sequence("GET staff/reservations", 200 to "staff_reservations_page1", 200 to "staff_reservations_page2") + } + openStaffHub() + click(text(R.string.staff_reservations)) + waitText("Page two title", timeoutMs = 15_000) + assertEquals("Mg", api.recorded("GET", "staff/reservations").last().requestUrl?.queryParameter("cursor")) + } + + @Test fun anAuthorityThatFailedToLoadCannotBeSaved() { + launch { + json("GET staff/archives/lookups", "archive_lookups") + json("GET staff/archives/authorities", "authorities_list") + body("GET staff/archives/authorities/7", 500, """{"data":null,"meta":{},"error":{"code":"internal_error","message":"Authority failed"}}""") + } + openStaffHub() + click(text(R.string.staff_authorities)) + click("Rossi, Mario") + waitText("Authority failed") + assertFalse(compose.onAllNodes(androidx.compose.ui.test.hasTestTag("authority-save")).fetchSemanticsNodes().isNotEmpty()) + api.json("GET staff/archives/authorities/7", "authority_7") + click(text(R.string.action_retry)) + waitText("Notaio.") + waitFor(androidx.compose.ui.test.hasTestTag("authority-save")) + assertTrue(api.recorded("PUT", "staff/archives/authorities/7").isEmpty()) + } + + @Test fun aFailedDuplicateListIsReportedWithRetry() { + launch { + body("GET staff/authors/duplicates", 500, """{"data":null,"meta":{},"error":{"code":"internal_error","message":"Duplicates failed"}}""") + } + openStaffHub() + click(text(R.string.staff_merges)) + waitText("Duplicates failed") + assertFalse(exists(text(R.string.staff_merge_no_duplicates))) + api.json("GET staff/authors/duplicates", "staff_author_duplicates") + click(text(R.string.action_retry)) + waitText(text(R.string.staff_merge_author_books, "Woolf, Virginia", 1)) + } + + @Test fun anImportThatEndedElsewhereIsNotResumable() { + launch { + json("GET staff/imports", "imports_overview") + json("GET staff/imports/6f1c2a", "import_job_created") + body("POST staff/imports/6f1c2a/chunk", 410, """{"data":null,"meta":{},"error":{"code":"gone","message":"Import expired"}}""") + } + openStaffHub() + click(text(R.string.staff_imports)) + scrollListTo("libri.csv") + click("libri.csv") + waitText(text(R.string.staff_import_status_gone)) + assertFalse(exists(text(R.string.staff_import_resume))) + assertFalse(exists(text(R.string.staff_import_cancel))) + } + + @Test fun staffExportsAreDeletedOnSignOut() { + launch() + val exports = File(context.cacheDir, "exports").apply { mkdirs() } + File(exports, "prestito_7_20261014.pdf").writeText("%PDF-1.4") + runBlocking { deps.authRepository().logout() } + assertFalse(File(exports, "prestito_7_20261014.pdf").exists()) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/ScannerFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/ScannerFlowTest.kt new file mode 100644 index 0000000..32120dc --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/ScannerFlowTest.kt @@ -0,0 +1,95 @@ +package com.pinakes.app.staffe2e + +import android.Manifest +import android.content.pm.PackageManager +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performTextInput +import androidx.test.platform.app.InstrumentationRegistry +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test + +/** The ISBN scanner, driven through the always-visible manual entry (as on an emulator). */ +class ScannerFlowTest : AppFlowTest() { + + private fun openScanner() { + openTab(R.string.nav_catalog) + clickDescription(text(R.string.staff_scan)) + denyCameraPromptIfShown() + waitText(text(R.string.staff_scan_manual_label)) + } + + private fun typeCode(code: String) { + compose.onAllNodes(hasSetTextAction() and hasText(text(R.string.staff_scan_manual_label))).onFirst().performTextInput(code) + clickDescription(text(R.string.staff_scan_lookup)) + } + + @Test fun cameraDeniedKeepsManualEntryAndExplainsWhy() { + // Revoking a granted permission from inside the test would kill this very process (it + // shares the app's UID) and take the rest of the suite with it: a device left with the + // camera granted is skipped with the command that resets it instead. + val context = InstrumentationRegistry.getInstrumentation().targetContext + assumeTrue( + "CAMERA is granted on this device: run `adb shell pm revoke ${context.packageName} android.permission.CAMERA` first", + context.checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED, + ) + launch() + openScanner() + // Denied (or never granted): the rationale or settings fallback replaces the preview, + // and the manual field stays usable. + assertTrue( + exists(text(R.string.staff_scan_permission_rationale)) || exists(text(R.string.staff_scan_permission_denied)) || + exists(text(R.string.staff_camera_unavailable)) + ) + waitText(text(R.string.staff_scan_manual_label)) + } + + @Test fun manualIsbnShowsThePreviewAndAddsTheTitle() { + launch { json("GET staff/isbn/9788845292613", "isbn_found"); json("POST staff/books", "staff_book_created", 201) } + openScanner() + typeCode("978-88-452-9261-3") + waitText("Il nome della rosa") + click(text(R.string.staff_scan_confirm)) + waitText(text(R.string.staff_scan_session_count, 1)) + val body = api.recorded("POST", "staff/books").single().body.readUtf8() + assertTrue(body, body.contains("\"isbn13\":\"9788845292613\"")) + assertTrue(body, body.contains("\"copies\":1")) + assertTrue(body, body.contains("\"authors_new\":[\"Umberto Eco\"]")) + } + + @Test fun duplicateOffersToAddCopies() { + launch { json("GET staff/isbn/9788845292613", "isbn_duplicate"); json("POST staff/books/42/copies", "copies_added", 201) } + openScanner() + typeCode("9788845292613") + waitText(text(R.string.staff_scan_duplicate)) + click(text(R.string.staff_scan_add_copy)) + waitText(text(R.string.staff_scan_added_copies, 1)) + assertEquals(1, api.recorded("POST", "staff/books/42/copies").size) + assertTrue(api.recorded("POST", "staff/books").isEmpty()) + } + + @Test fun notFoundLeadsToTheManualFormWithTheCode() { + launch { json("GET staff/isbn/4006381333931", "isbn_not_found") } + openScanner() + typeCode("4006381333931") + waitText(text(R.string.staff_scan_not_found)) + click(text(R.string.staff_scan_create_manual)) + waitText(text(R.string.staff_book_new)) + waitText("4006381333931") + assertTrue(api.recorded("POST", "staff/books").isEmpty()) + } + + @Test fun invalidCodeIsRejectedWithoutALookup() { + launch() + openScanner() + typeCode("9788845292614") + waitText(text(R.string.staff_identifier_invalid)) + assertTrue(api.requests.none { it.requestUrl?.encodedPath?.contains("staff/isbn") == true }) + assertTrue(compose.onAllNodes(hasContentDescription(text(R.string.staff_scan_lookup))).fetchSemanticsNodes().isNotEmpty()) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/SeriesFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/SeriesFlowTest.kt new file mode 100644 index 0000000..4be325d --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/SeriesFlowTest.kt @@ -0,0 +1,54 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performTextReplacement +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Hub → Series: the tree, a series' books, a number, and a rename that clashes and merges. */ +class SeriesFlowTest : AppFlowTest() { + + private fun openRincewind(configure: FakePinakes.() -> Unit = {}) { + launch { + json("GET staff/series", "staff_series") + json("GET staff/series/5", "staff_series_5") + configure() + } + openStaffHub() + click(text(R.string.staff_series)) + waitText("Discworld") + assertTrue(!exists("Rincewind")) + clickDescription(text(R.string.staff_expand)) + click("Rincewind") + waitText("Sourcery") + } + + @Test fun numberABookInTheSeries() { + openRincewind { json("PUT staff/series/5/books/7", "staff_series_5_numbered") } + clickTag("series-book-7") + typeInto(text(R.string.staff_field_series_number), "2") + clickInDialog(text(R.string.action_save)) + assertEquals("""{"series_number":"2"}""", lastBody("PUT", "staff/series/5/books/7")) + waitText(text(R.string.staff_series_number_saved)) + } + + @Test fun renameOntoAnExistingNameAsksAndMerges() { + openRincewind { + body("POST staff/series/5/rename", 409, """{"data":null,"meta":{"existing":{"id":6,"name":"Gialli Mondadori"}},"error":{"code":"duplicate","message":"x"}}""") + } + clickDescription(text(R.string.staff_more_actions)) + click(text(R.string.staff_series_rename)) + compose.onAllNodes(hasSetTextAction() and hasTestTag("text-input")).onFirst().performTextReplacement("Gialli Mondadori") + clickInDialog(text(R.string.staff_series_rename)) + waitText(text(R.string.staff_series_rename_clash, "Gialli Mondadori")) + assertTrue(lastBody("POST", "staff/series/5/rename").contains("\"merge_if_exists\":false")) + api.json("POST staff/series/5/rename", "staff_series_5") + clickInDialog(text(R.string.staff_series_merge_into, "Gialli Mondadori")) + compose.waitUntil(10_000) { api.recorded("POST", "staff/series/5/rename").size == 2 } + assertTrue(api.recorded("POST", "staff/series/5/rename").last().body.clone().readUtf8().contains("\"merge_if_exists\":true")) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/StaffAccessFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/StaffAccessFlowTest.kt new file mode 100644 index 0000000..b1ec449 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/StaffAccessFlowTest.kt @@ -0,0 +1,55 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasText +import com.pinakes.app.R +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The staff area shows only for admin/staff users on a server that advertises staff_catalog. */ +class StaffAccessFlowTest : AppFlowTest() { + + @Test fun adminSeesTheStaffAreaAndTheCatalogScanButton() { + launch(role = "admin") + openTab(R.string.nav_profile) + waitText(text(R.string.profile_action_staff)) + openTab(R.string.nav_catalog) + waitFor(hasContentDescription(text(R.string.staff_scan))) + } + + @Test fun staffRoleSeesTheStaffArea() { + launch(role = "staff") { json("GET me", "me_staff") } + openTab(R.string.nav_profile) + waitText(text(R.string.profile_action_staff)) + } + + @Test fun standardUserSeesNeitherEntry() { + launch(role = "utente") { json("GET me", "me_user") } + openTab(R.string.nav_profile) + waitText(text(R.string.profile_action_edit)) + assertFalse(exists(text(R.string.profile_action_staff))) + openTab(R.string.nav_catalog) + compose.waitForIdle() + assertTrue(compose.onAllNodes(hasContentDescription(text(R.string.staff_scan))).fetchSemanticsNodes().isEmpty()) + } + + @Test fun adminOnAServerWithoutStaffCatalogSeesNoStaffEntry() { + launch(role = "admin", health = "health_no_staff") + openTab(R.string.nav_profile) + waitText(text(R.string.profile_action_edit)) + assertFalse(exists(text(R.string.profile_action_staff))) + openTab(R.string.nav_catalog) + compose.waitForIdle() + assertTrue(compose.onAllNodes(hasContentDescription(text(R.string.staff_scan))).fetchSemanticsNodes().isEmpty()) + } + + @Test fun roleDowngradedOnTheServerHidesTheStaffAreaAndUpdatesTheHeader() { + // Stored role says admin, but GET /me (the authoritative read) now says utente. + launch(role = "admin") { json("GET me", "me_user") } + openTab(R.string.nav_profile) + waitText("Ugo Admin") + waitGone(hasText(text(R.string.profile_action_staff))) + assertFalse(exists("Admin", substring = false)) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/StaffOfflineFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/StaffOfflineFlowTest.kt new file mode 100644 index 0000000..50b3338 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/StaffOfflineFlowTest.kt @@ -0,0 +1,44 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Offline the circulation section is hidden (not disabled); the book tools stay; back online it returns. */ +class StaffOfflineFlowTest : AppFlowTest() { + + @Test fun circulationDisappearsOfflineAndComesBack() { + launch { json("GET staff/circulation/summary", "circulation_summary") } + openStaffHub() + waitText(text(R.string.staff_circulation_desk)) + setAirplaneMode(true) + waitGone(androidx.compose.ui.test.hasText(text(R.string.staff_circulation_desk)), timeoutMs = 15_000) + assertFalse(exists(text(R.string.staff_scan_copy))) + assertFalse(exists(text(R.string.staff_loan_new))) + waitText(text(R.string.staff_offline_circulation_hidden)) + assertTrue(exists(text(R.string.staff_books))) + assertTrue(exists(text(R.string.staff_section_books).uppercase())) + setAirplaneMode(false) + waitText(text(R.string.staff_circulation_desk), timeoutMs = 20_000) + } + + @Test fun anOpenDeskScreenClosesWhenTheDeviceGoesOffline() { + launch { json("GET staff/circulation/summary", "circulation_summary") } + openStaffHub() + click(text(R.string.staff_circulation_desk)) + waitText(text(R.string.staff_circulation_today, 1, 2)) + setAirplaneMode(true) + waitText(text(R.string.staff_offline_circulation_hidden), timeoutMs = 15_000) + } + + @Test fun withoutTheFlagThereIsNoCirculationSection() { + launch(health = "health_staff_phase1") + openStaffHub() + waitText(text(R.string.staff_books)) + assertFalse(exists(text(R.string.staff_circulation_desk))) + assertFalse(exists(text(R.string.staff_offline_circulation_hidden))) + assertFalse(exists(text(R.string.staff_section_books).uppercase())) + assertFalse(exists(text(R.string.staff_section_plugins).uppercase())) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/StaffRecordDetailFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/StaffRecordDetailFlowTest.kt new file mode 100644 index 0000000..a82e46c --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/StaffRecordDetailFlowTest.kt @@ -0,0 +1,29 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.hasTestTag +import com.pinakes.app.R +import org.junit.Test + +/** Staff → Authors → an author: padded info card (count, dates) and the author's books. */ +class StaffRecordDetailFlowTest : AppFlowTest() { + + @Test fun authorDetailShowsTheInfoCardAndTheBooks() { + launch { + json("GET staff/authors", "staff_authors") + json("GET staff/authors/3", "staff_author_3") + } + openTab(R.string.nav_profile) + clickUntil(text(R.string.profile_action_staff), text(R.string.staff_scan_subtitle)) + click(text(R.string.staff_authors)) + click("Virginia Woolf") + waitFor(hasTestTag("record-info-card")) + waitText(text(R.string.staff_record_books_count)) + waitText(text(R.string.staff_record_created)) + waitText("118635506") + // The book list (GET /staff/books?author_id=3) uses the whole-cover cards. + waitText("Orlando") + waitRequest("GET", "staff/books") + val query = api.recorded("GET", "staff/books").last().requestUrl?.queryParameter("author_id") + org.junit.Assert.assertEquals("3", query) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/Tier2FlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/Tier2FlowTest.kt new file mode 100644 index 0000000..c8958ca --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/Tier2FlowTest.kt @@ -0,0 +1,135 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performTextReplacement +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Tier 2 bridges: periodicals desk, book club management and FRBR works, each behind its own flag. */ +class Tier2FlowTest : AppFlowTest() { + + private fun tier2(configure: FakePinakes.() -> Unit) = launch(health = "health_staff_tier2", configure = configure) + + @Test fun withoutTheirFlagsTheTier2RowsAreHidden() { + launch() + openStaffHub() + waitText(text(R.string.staff_books)) + assertFalse(exists(text(R.string.staff_periodicals))) + assertFalse(exists(text(R.string.staff_clubs))) + assertFalse(exists(text(R.string.staff_works))) + } + + @Test fun aTypedIssueCodeIsReceived() { + tier2 { + json("GET periodicals", "periodicals_list") + json("GET periodicals/staff/scan-lookup", "scan_issue") + json("POST periodicals/staff/issues/4/receive", "issue_4_received") + } + openStaffHub() + click(text(R.string.staff_periodicals)) + denyCameraPromptIfShown() + typeInto(text(R.string.staff_periodicals_code), "977112228320612") + clickDescription(text(R.string.staff_scan_lookup)) + waitRequest("GET", "periodicals/staff/scan-lookup") + assertEquals("977112228320612", api.recorded("GET", "periodicals/staff/scan-lookup").last().requestUrl?.queryParameter("code")) + clickTag("issue-receive") + waitRequest("POST", "periodicals/staff/issues/4/receive") + waitText(text(R.string.staff_issue_received, "12", "Internazionale")) + } + + @Test fun aMastheadShowsItsIssuesAndTakesANewYear() { + tier2 { + json("GET periodicals", "periodicals_list") + json("GET periodicals/16", "periodical_16") + json("GET periodicals/years/2/issues", "year_2_issues") + json("GET periodicals/staff/subscriptions", "subscriptions_empty") + json("POST periodicals/staff/16/years", "year_created", status = 201) + } + openStaffHub() + click(text(R.string.staff_periodicals)) + denyCameraPromptIfShown() + scrollListTo("Internazionale") + click("Internazionale") + clickTag("year-2") + waitText(text(R.string.staff_issue_number, "12")) + assertTrue(exists(text(R.string.staff_issue_receive))) + clickDescription(text(R.string.staff_year_add)) + compose.onAllNodes(hasSetTextAction() and hasTestTag("year-input")).onFirst().performTextReplacement("2027") + typeInto(text(R.string.staff_year_volume), "34") + clickTag("form-save") + val body = lastBody("POST", "periodicals/staff/16/years") + assertTrue(body, body.contains("\"year\":2027") && body.contains("\"volume\":\"34\"")) + waitText(text(R.string.staff_year_added)) + } + + @Test fun createAClubThenChangeAMemberRole() { + tier2 { + json("GET bookclub/staff/clubs", "clubs_list") + json("POST bookclub/staff/clubs", "club_1132", status = 201) + json("GET bookclub/staff/clubs/1132", "club_1132") + json("PUT bookclub/staff/clubs/1132/members/5", "club_member_5_moderator") + } + openStaffHub() + click(text(R.string.staff_clubs)) + clickDescription(text(R.string.staff_club_new)) + compose.onAllNodes(hasSetTextAction() and hasTestTag("club-name")).onFirst().performTextReplacement("Lettori del giovedì") + clickTag("form-save") + val body = lastBody("POST", "bookclub/staff/clubs") + assertTrue(body, body.contains("\"name\":\"Lettori del giovedì\"") && body.contains("\"privacy\":\"public\"")) + waitText("Ada Rossi") + click("Ada Rossi") + clickTag("role-moderator") + assertEquals("""{"role":"moderator"}""", lastBody("PUT", "bookclub/staff/clubs/1132/members/5")) + waitText(text(R.string.staff_club_member_saved)) + } + + @Test fun editTheClubWorkflow() { + tier2 { + json("GET bookclub/staff/clubs", "clubs_list") + json("GET bookclub/staff/clubs/1132", "club_1132") + json("PUT bookclub/staff/clubs/1132/workflow", "club_1132") + } + openStaffHub() + click(text(R.string.staff_clubs)) + click("Lettori del giovedì") + click(text(R.string.staff_club_workflow_edit)) + click(text(R.string.staff_club_state_add)) + clickTag("form-save") + val body = lastBody("PUT", "bookclub/staff/clubs/1132/workflow") + assertEquals(4, Regex("\"label\"").findAll(body).count()) + assertTrue(body, body.contains("\"voting\":true")) + waitText(text(R.string.staff_club_workflow_saved)) + } + + @Test fun createAWorkAddAnExpressionAndLinkABook() { + tier2 { + json("GET staff/works", "works_list") + json("POST staff/works", "work_2", status = 201) + json("GET staff/works/2", "work_2") + json("POST staff/works/2/expressions", "expression_created", status = 201) + json("POST staff/books/5/work", "book_work_link") + } + openStaffHub() + click(text(R.string.staff_works)) + clickDescription(text(R.string.staff_work_new)) + compose.onAllNodes(hasSetTextAction() and hasTestTag("work-title")).onFirst().performTextReplacement("Odissea") + clickTag("form-save") + assertTrue(lastBody("POST", "staff/works").contains("\"uniform_title\":\"Odissea\"")) + waitText(text(R.string.staff_work_expressions).uppercase()) + click(text(R.string.staff_expression_add)) + compose.onAllNodes(hasSetTextAction() and hasTestTag("expression-title")).onFirst().performTextReplacement("Odissea (trad. Calzecchi Onesti)") + clickTag("form-save") + val expr = lastBody("POST", "staff/works/2/expressions") + assertTrue(expr, expr.contains("\"type\":\"testo\"") && expr.contains("Calzecchi")) + click(text(R.string.staff_work_attach)) + typeIntoTag("picker-search", "Orl") + clickTag("pick-5") + assertEquals("""{"work_id":2}""", lastBody("POST", "staff/books/5/work")) + waitText(text(R.string.staff_work_book_attached)) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d0e133a..1dbd73b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,10 @@ + + + + + + + + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 37ed268..ef2509f 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -11,14 +11,23 @@ crash). We therefore exclude both files from both backup paths; the user simply re-onboards + logs in after a restore, which re-creates a valid local session and re-fetches the feature flags. + + The Room cache (pinakes-cache.db: offline catalogue, loans, wishlist) is excluded + too: it belongs to one instance and one user and the next sync rebuilds it. --> + + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..11c5aec --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/test/java/com/pinakes/app/CachedBookMapperEdgeTest.kt b/app/src/test/java/com/pinakes/app/CachedBookMapperEdgeTest.kt index 2e6ec75..e979b33 100644 --- a/app/src/test/java/com/pinakes/app/CachedBookMapperEdgeTest.kt +++ b/app/src/test/java/com/pinakes/app/CachedBookMapperEdgeTest.kt @@ -3,17 +3,18 @@ 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 com.pinakes.app.data.model.SyncBook 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). */ +/** Extra round-trip/edge cases for the offline-cache entity↔summary mapping. */ class CachedBookMapperEdgeTest { @Test fun allOptionalNullsRoundTrip() { val s = BookSummary(id = 1, title = "Bare") // every optional left null/default - assertEquals(s, s.toCached(0).toSummary()) + assertEquals(s, s.toCached().toSummary()) } @Test fun fullyPopulatedRoundTrips() { @@ -22,19 +23,12 @@ class CachedBookMapperEdgeTest { 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 + assertEquals(s, s.toCached().toSummary()) } @Test fun availabilityFlagsSurviveTheRoundTrip() { val notLoanableButCopies = BookSummary(id = 1, loanableNow = false, copiesAvailable = 3) - val back = notLoanableButCopies.toCached(0).toSummary() + val back = notLoanableButCopies.toCached().toSummary() assertFalse(back.loanableNow) assertEquals(3, back.copiesAvailable) assertTrue(back.available) // derived: copies > 0 @@ -42,8 +36,14 @@ class CachedBookMapperEdgeTest { @Test fun loanableNowTrueSurvives() { val loanable = BookSummary(id = 2, loanableNow = true, copiesAvailable = 0) - val back = loanable.toCached(1).toSummary() + val back = loanable.toCached().toSummary() assertTrue(back.loanableNow) assertTrue(back.available) } + + @Test fun syncRowWithFreeCopiesIsLoanable() { + // The sync payload may omit loanable_now; free copies still make it loanable. + val c = SyncBook(id = 3, title = "X", copiesAvailable = 1, loanableNow = false).toCached() + assertTrue(c.loanableNow) + } } diff --git a/app/src/test/java/com/pinakes/app/CachedBookMapperTest.kt b/app/src/test/java/com/pinakes/app/CachedBookMapperTest.kt index 5b9fe40..962eb2e 100644 --- a/app/src/test/java/com/pinakes/app/CachedBookMapperTest.kt +++ b/app/src/test/java/com/pinakes/app/CachedBookMapperTest.kt @@ -1,12 +1,17 @@ package com.pinakes.app import com.pinakes.app.data.local.toCached +import com.pinakes.app.data.local.toOfflineDetail import com.pinakes.app.data.local.toSummary import com.pinakes.app.data.model.BookSummary +import com.pinakes.app.data.model.SyncBook import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test -/** Entity ↔ summary mapping that backs the offline catalog cache. */ +/** Entity ↔ model mapping behind the offline catalogue (Room schema v2). */ class CachedBookMapperTest { private val sample = BookSummary( @@ -26,15 +31,58 @@ class CachedBookMapperTest { loanableNow = true, ) - @Test fun roundTripPreservesAllFields() { - assertEquals(sample, sample.toCached(0).toSummary()) - } + private val syncBook = SyncBook( + id = 9, title = "Le onde", author = "Virginia Woolf", publisher = "Einaudi", genre = "Romanzo", + year = 1931, language = "Italiano", mediaType = "libro", isbn13 = "9788806219130", isbn10 = "8806219138", + ean = null, pages = 260, description = "Sei voci.", locationLabel = "A.2.14", + coverUrl = "https://lib.example.org/c/9.jpg", copiesTotal = 2, copiesAvailable = 0, + loanableNow = false, isWanted = true, updatedAt = "2026-10-01 10:00:00", + ) - @Test fun cachedKeepsPosition() { - assertEquals(5, sample.toCached(5).position) + @Test fun summaryRoundTripPreservesAllFields() { + assertEquals(sample, sample.toCached().toSummary()) } @Test fun cachedKeepsPrimaryKey() { - assertEquals(7, sample.toCached(0).id) + assertEquals(7, sample.toCached().id) + } + + @Test fun summaryRowsHaveNoChangeTimestamp() { + assertEquals("", sample.toCached().updatedAt) + } + + @Test fun syncBookKeepsOfflineDetailFields() { + val c = syncBook.toCached() + assertEquals("8806219138", c.isbn10) + assertEquals(260, c.pages) + assertEquals("Sei voci.", c.description) + assertEquals("A.2.14", c.locationLabel) + assertTrue(c.isWanted) + assertEquals("2026-10-01 10:00:00", c.updatedAt) + } + + @Test fun syncBookWithoutTimestampStoresEmpty() { + assertEquals("", syncBook.copy(updatedAt = null).toCached().updatedAt) + } + + @Test fun offlineDetailCarriesSummaryLevelData() { + val d = syncBook.toCached().toOfflineDetail() + assertEquals(9, d.id) + assertEquals("Le onde", d.title) + assertEquals("Virginia Woolf", d.authorsLabel) + assertEquals("Einaudi", d.publisher) + assertEquals("Romanzo", d.genreLabel) + assertEquals(260, d.pages) + assertEquals("A.2.14", d.locationLabel) + assertEquals(2, d.copiesTotal) + assertFalse(d.available) + // Server-only data stays empty offline. + assertNull(d.personalHistory) + assertTrue(d.digitalFiles.isEmpty()) + } + + @Test fun offlineDetailWithoutAuthorHasNoAuthorEntry() { + val d = syncBook.copy(author = null).toCached().toOfflineDetail() + assertTrue(d.authors.isEmpty()) } } diff --git a/app/src/test/java/com/pinakes/app/CatalogDaoEdgeTest.kt b/app/src/test/java/com/pinakes/app/CatalogDaoEdgeTest.kt index 2ea000b..299a4d9 100644 --- a/app/src/test/java/com/pinakes/app/CatalogDaoEdgeTest.kt +++ b/app/src/test/java/com/pinakes/app/CatalogDaoEdgeTest.kt @@ -15,7 +15,7 @@ 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). */ +/** Extra edge cases for the offline-catalogue Room cache. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = android.app.Application::class) class CatalogDaoEdgeTest { @@ -23,11 +23,7 @@ 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, - ) + private fun book(id: Int) = CachedBook(id = id, title = "Book $id", copiesTotal = 1, copiesAvailable = 1, loanableNow = true) @Before fun setup() { db = Room.inMemoryDatabaseBuilder( @@ -39,37 +35,38 @@ class CatalogDaoEdgeTest { @After fun teardown() = db.close() @Test fun replaceAllWithEmptyListClears() = runBlocking { - dao.insertAll(listOf(book(1, 0), book(2, 1))) + dao.upsertAll(listOf(book(1), book(2))) 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 }) + assertEquals(emptyList(), dao.observeNewest(40).first()) } @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 }) + dao.replaceAll(listOf(book(1), book(2), book(3))) + dao.replaceAll(listOf(book(7))) + assertEquals(listOf(7), dao.observeNewest(40).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 deleteIdsWithEmptyListIsANoOp() = runBlocking { + dao.upsertAll(listOf(book(1))) + dao.applyDelta(upserts = emptyList(), deleteIds = emptyList()) + assertEquals(1, dao.count()) } - @Test fun largeBatchRoundTripsInPositionOrder() = runBlocking { - val books = (0 until 50).map { book(id = 1000 - it, position = it) } + @Test fun largeCatalogueReplacesInChunks() = runBlocking { + // Larger than the 500-row chunk: the whole snapshot still lands in one transaction. + val books = (1..1_250).map { book(it) } dao.replaceAll(books) - val out = dao.observeAll().first() - assertEquals(50, out.size) - assertEquals((0 until 50).toList(), out.map { it.position }) + assertEquals(1_250, dao.count()) + dao.applyDelta(upserts = emptyList(), deleteIds = (1..1_100).toList()) + assertEquals(150, dao.count()) + } + + @Test fun emptyQueryListsAllAndUnknownQueryListsNone() = runBlocking { + dao.upsertAll(listOf(book(1))) + // An empty query lists everything; a query with no match lists nothing. + assertEquals(1, dao.search("", "", "", "", "", false, "title_asc", 10, 0).size) + assertEquals(0, dao.search("zzz", "", "", "", "", false, "title_asc", 10, 0).size) } } diff --git a/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt b/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt index c14e017..8177a47 100644 --- a/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt +++ b/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt @@ -5,17 +5,19 @@ 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 com.pinakes.app.data.local.SearchText import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull 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. */ +/** The offline catalogue (Room v2). 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 @@ -26,18 +28,35 @@ 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, + private fun book( + id: Int, + title: String = "Book $id", + updatedAt: String = "", + author: String? = null, + publisher: String? = null, + isbn13: String? = null, + language: String? = null, + genre: String? = null, + available: Int = 1, + ) = CachedBook( + id = id, title = title, author = author, publisher = publisher, isbn13 = isbn13, + language = language, genre = genre, copiesTotal = 1, copiesAvailable = available, + loanableNow = available > 0, updatedAt = updatedAt, ) + private suspend fun search( + q: String = "", author: String = "", publisher: String = "", language: String = "", + genre: String = "", availableOnly: Boolean = false, sort: String = "title_asc", + limit: Int = 50, offset: Int = 0, + ) = dao.search( + SearchText.queryPattern(q), SearchText.escapeLike(author), SearchText.escapeLike(publisher), + language, genre, availableOnly, sort, limit, offset, + ).map { it.id } + @Before fun setup() { - db = Room.inMemoryDatabaseBuilder( - ApplicationProvider.getApplicationContext(), - AppDatabase::class.java, - ).allowMainThreadQueries().build() + db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AppDatabase::class.java) + .allowMainThreadQueries().build() dao = db.catalogDao() } @@ -47,41 +66,150 @@ class CatalogDaoTest { @Test fun emptyByDefault() = runBlocking { assertEquals(0, dao.count()) - assertEquals(emptyList(), dao.observeAll().first()) + assertEquals(emptyList(), dao.observeNewest(40).first()) + } + + @Test + fun upsertAllReplacesById() = runBlocking { + dao.upsertAll(listOf(book(1, title = "old"))) + dao.upsertAll(listOf(book(1, title = "new"))) + assertEquals(1, dao.count()) + assertEquals("new", dao.byId(1)?.title) } @Test - fun insertAndObserve() = runBlocking { - dao.insertAll(listOf(book(1, 0), book(2, 1))) - assertEquals(2, dao.count()) - assertEquals(2, dao.observeAll().first().size) + fun deleteIdsRemovesOnlyThoseRows() = runBlocking { + dao.upsertAll(listOf(book(1), book(2), book(3))) + dao.deleteIds(listOf(1, 3, 99)) + assertEquals(listOf(2), search()) } @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 }) + fun byIdIsNullForUnknown() = runBlocking { + assertNull(dao.byId(5)) } @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) + fun observeNewestOrdersByChangeTimeThenId() = runBlocking { + dao.upsertAll( + listOf( + book(1, updatedAt = "2026-01-01 10:00:00"), + book(2, updatedAt = "2026-03-01 10:00:00"), + book(3, updatedAt = "2026-03-01 10:00:00"), + book(4, updatedAt = ""), + ) + ) + assertEquals(listOf(3, 2, 1, 4), dao.observeNewest(10).first().map { it.id }) + assertEquals(listOf(3, 2), dao.observeNewest(2).first().map { it.id }) } @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()) + dao.upsertAll(listOf(book(1), book(2))) + dao.replaceAll(listOf(book(9))) + assertEquals(listOf(9), search()) + } + + @Test + fun applyDeltaUpsertsAndDeletesTogether() = runBlocking { + dao.upsertAll(listOf(book(1), book(2, title = "before"))) + dao.applyDelta(upserts = listOf(book(2, title = "after"), book(3)), deleteIds = listOf(1)) + assertEquals(listOf(2, 3), search().sorted()) + assertEquals("after", dao.byId(2)?.title) + } + + @Test + fun searchMatchesTitleAuthorPublisherAndIsbn() = runBlocking { + dao.upsertAll( + listOf( + book(1, title = "Orlando", author = "Virginia Woolf"), + book(2, title = "Ulysses", author = "James Joyce", publisher = "Shakespeare and Co"), + book(3, title = "Dubliners", isbn13 = "9780140186475"), + ) + ) + assertEquals(listOf(1), search(q = "orlan")) // case-insensitive + assertEquals(listOf(1), search(q = "woolf")) + assertEquals(listOf(2), search(q = "shakespeare")) + assertEquals(listOf(3), search(q = "0140186")) + assertEquals(emptyList(), search(q = "proust")) + } + + @Test + fun searchFiltersByLanguageGenreAuthorAndAvailability() = runBlocking { + dao.upsertAll( + listOf( + book(1, title = "A", language = "Italiano", genre = "Romanzo", author = "Calvino", available = 1), + book(2, title = "B", language = "Deutsch", genre = "Romanzo", author = "Mann", available = 0), + book(3, title = "C", language = "Italiano", genre = "Poesia", author = "Montale", available = 0), + ) + ) + assertEquals(listOf(1, 3), search(language = "Italiano")) + assertEquals(listOf(1, 2), search(genre = "Romanzo")) + assertEquals(listOf(2), search(author = "mann")) + assertEquals(listOf(1), search(availableOnly = true)) + assertEquals(listOf(1), search(language = "Italiano", genre = "Romanzo")) + } + + @Test + fun searchSortsAndPages() = runBlocking { + dao.upsertAll( + listOf( + book(1, title = "beta", updatedAt = "2026-01-02 00:00:00"), + book(2, title = "Alpha", updatedAt = "2026-01-01 00:00:00"), + book(3, title = "gamma", updatedAt = "2026-01-03 00:00:00"), + ) + ) + assertEquals(listOf(2, 1, 3), search(sort = "title_asc")) + assertEquals(listOf(3, 1, 2), search(sort = "title_desc")) + assertEquals(listOf(3, 1, 2), search(sort = "newest")) + assertEquals(listOf(2, 1), search(limit = 2)) + assertEquals(listOf(3), search(limit = 2, offset = 2)) } @Test fun clearEmptiesTheCache() = runBlocking { - dao.insertAll(listOf(book(1, 0))) + dao.upsertAll(listOf(book(1))) dao.clear() assertEquals(0, dao.count()) } + + @Test + fun searchIgnoresAccentsAndCaseBeyondAscii() = runBlocking { + dao.upsertAll( + listOf( + book(1, title = "La città invisibile"), + book(2, title = "Über den Fluss", author = "Kästner"), + book(3, title = "Ærø"), + ) + ) + assertEquals(listOf(1), search(q = "CITTA")) + assertEquals(listOf(1), search(q = "Città")) + assertEquals(listOf(2), search(q = "uber")) + assertEquals(listOf(2), search(q = "kastner")) + assertEquals(listOf(3), search(q = "ærø")) // not decomposable: still case-folded + assertEquals(listOf(3), search(q = "ÆRØ")) + } + + @Test + fun likeWildcardsInTheQueryMatchThemselves() = runBlocking { + dao.upsertAll(listOf(book(1, title = "100% Cotone"), book(2, title = "1000 Cotone"), book(3, title = "snake_case"), book(4, title = "snakeXcase"))) + assertEquals(listOf(1), search(q = "100%")) + assertEquals(listOf(3), search(q = "snake_case")) + assertEquals(emptyList(), search(q = "\\")) + assertEquals(listOf(3), search(author = "", q = "e_c")) + } + + @Test + fun isbnQueryWithDashesOrSpacesMatchesTheCompactIdentifier() = runBlocking { + dao.upsertAll(listOf(book(1, isbn13 = "9788804668237"), book(2, isbn13 = "978-0-14-018647-5"))) + assertEquals(listOf(1), search(q = "978-88-04-66823-7")) + assertEquals(listOf(1), search(q = "978 88 04")) + assertEquals(listOf(2), search(q = "9780140186475")) // stored with dashes, still found + } + + @Test + fun authorFilterEscapesWildcards() = runBlocking { + dao.upsertAll(listOf(book(1, author = "A_B"), book(2, author = "AxB"))) + assertEquals(listOf(1), search(author = "A_B")) + } } diff --git a/app/src/test/java/com/pinakes/app/DateFormatLocaleTest.kt b/app/src/test/java/com/pinakes/app/DateFormatLocaleTest.kt new file mode 100644 index 0000000..b664abc --- /dev/null +++ b/app/src/test/java/com/pinakes/app/DateFormatLocaleTest.kt @@ -0,0 +1,39 @@ +package com.pinakes.app + +import com.pinakes.app.ui.common.DateFormat +import java.util.Locale +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * The app language can change while the process keeps running (Profile → Language). Dates + * must follow it on the next render instead of keeping the language of the first format call. + */ +class DateFormatLocaleTest { + private lateinit var saved: Locale + + @Before fun save() { saved = Locale.getDefault() } + + @After fun restore() { Locale.setDefault(saved) } + + @Test fun date_followsALanguageChangeInTheSameProcess() { + Locale.setDefault(Locale.US) + val english = DateFormat.date("2026-10-09") + Locale.setDefault(Locale.ITALY) + val italian = DateFormat.date("2026-10-09") + assertTrue("english was $english", english.contains("Oct")) + assertTrue("italian was $italian", italian.contains("ott")) + } + + @Test fun dateTime_followsALanguageChangeInTheSameProcess() { + Locale.setDefault(Locale.US) + val english = DateFormat.dateTime("2026-10-09 19:53:00") + Locale.setDefault(Locale.GERMANY) + val german = DateFormat.dateTime("2026-10-09 19:53:00") + assertTrue("english was $english", english.contains("Oct")) + // German MEDIUM dates are numeric (09.10.2026). + assertTrue("german was $german", german.startsWith("09.10.2026")) + } +} diff --git a/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt b/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt new file mode 100644 index 0000000..ff3e4ea --- /dev/null +++ b/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt @@ -0,0 +1,119 @@ +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.CachedLoan +import com.pinakes.app.data.local.LibraryDao +import com.pinakes.app.data.local.SyncMeta +import com.pinakes.app.data.local.SyncMetaDao +import com.pinakes.app.data.local.toCached +import com.pinakes.app.data.local.toLoansData +import com.pinakes.app.data.local.toReservationItem +import com.pinakes.app.data.local.toWishlistItem +import com.pinakes.app.data.model.LoanItem +import com.pinakes.app.data.model.LoansData +import com.pinakes.app.data.model.ReservationItem +import com.pinakes.app.data.model.WishlistItem +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Offline copies of the personal lists and the sync bookkeeping (Room v2). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class) +class LibraryDaoTest { + + private lateinit var db: AppDatabase + private lateinit var dao: LibraryDao + private lateinit var meta: SyncMetaDao + + @Before fun setup() { + db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AppDatabase::class.java) + .allowMainThreadQueries().build() + dao = db.libraryDao() + meta = db.syncMetaDao() + } + + @After fun teardown() = db.close() + + private val loans = LoansData( + pending = listOf(LoanItem(id = 3, bookId = 30, title = "Pending", status = "pendente", cancellable = true)), + active = listOf( + LoanItem(id = 1, bookId = 10, title = "First", status = "in_corso", dueAt = "2026-11-01"), + LoanItem(id = 2, bookId = 20, title = "Second", status = "in_ritardo", dueAttention = true), + ), + history = listOf(LoanItem(id = 4, bookId = 40, title = "Old", status = "restituito", returnedAt = "2026-01-01")), + ) + + @Test fun loansRoundTripPerBucketInServerOrder() = runBlocking { + dao.replaceLoans(CachedLoan.LOAN_BUCKETS, loans.toCached()) + val back = dao.loans(CachedLoan.LOAN_BUCKETS).toLoansData() + assertEquals(loans, back) + } + + @Test fun replacingLoansKeepsReservations() = runBlocking { + dao.replaceLoans(listOf(CachedLoan.RESERVATION), listOf(ReservationItem(id = 9, bookId = 90, title = "R", queuePosition = 2).toCached(0))) + dao.replaceLoans(CachedLoan.LOAN_BUCKETS, loans.toCached()) + dao.replaceLoans(CachedLoan.LOAN_BUCKETS, LoansData().toCached()) + assertTrue(dao.loans(CachedLoan.LOAN_BUCKETS).isEmpty()) + val r = dao.loans(listOf(CachedLoan.RESERVATION)).single().toReservationItem() + assertEquals(2, r.queuePosition) + } + + @Test fun sameIdInDifferentBucketsDoesNotCollide() = runBlocking { + dao.replaceLoans(listOf(CachedLoan.ACTIVE, CachedLoan.RESERVATION), listOf( + LoanItem(id = 1, title = "Loan").toCached(CachedLoan.ACTIVE, 0), + ReservationItem(id = 1, title = "Reservation").toCached(0), + )) + assertEquals(2, dao.loans(listOf(CachedLoan.ACTIVE, CachedLoan.RESERVATION)).size) + } + + @Test fun wishlistReplaceKeepsOrder() = runBlocking { + val items = listOf(WishlistItem(bookId = 5, title = "B"), WishlistItem(bookId = 2, title = "A", copiesAvailable = 1)) + dao.replaceWishlist(items.mapIndexed { i, w -> w.toCached(i) }) + assertEquals(items, dao.wishlist().map { it.toWishlistItem() }) + dao.replaceWishlist(emptyList()) + assertTrue(dao.wishlist().isEmpty()) + } + + @Test fun clearAllDropsEverythingPersonal() = runBlocking { + dao.replaceLoans(CachedLoan.LOAN_BUCKETS, loans.toCached()) + dao.replaceWishlist(listOf(WishlistItem(bookId = 1, title = "W").toCached(0))) + dao.clearAll() + assertTrue(dao.loans(CachedLoan.LOAN_BUCKETS + CachedLoan.RESERVATION).isEmpty()) + assertTrue(dao.wishlist().isEmpty()) + } + + @Test fun syncMetaStoresAndReplacesValues() = runBlocking { + assertNull(meta.get(SyncMeta.CATALOG_SERVER_TIME)) + meta.put(SyncMeta(SyncMeta.CATALOG_SERVER_TIME, "2026-10-13 10:00:00")) + meta.put(SyncMeta(SyncMeta.CATALOG_SERVER_TIME, "2026-10-13 11:00:00")) + assertEquals("2026-10-13 11:00:00", meta.get(SyncMeta.CATALOG_SERVER_TIME)) + meta.clear() + assertNull(meta.get(SyncMeta.CATALOG_SERVER_TIME)) + } + + @Test fun cancelAndRemoveUpdateTheOfflineCopyWithoutAFullFetch() = runBlocking { + dao.replaceLoans(CachedLoan.LOAN_BUCKETS, loans.toCached()) + dao.replaceLoans(listOf(CachedLoan.RESERVATION), listOf(ReservationItem(id = 9, bookId = 90, title = "R").toCached(0))) + dao.deleteLoan(3, listOf(CachedLoan.PENDING, CachedLoan.ACTIVE)) + dao.deleteLoan(9, listOf(CachedLoan.RESERVATION)) + val back = dao.loans(CachedLoan.LOAN_BUCKETS).toLoansData() + assertTrue(back.pending.isEmpty()) + assertEquals(2, back.active.size) + assertEquals(1, back.history.size) // other buckets untouched + assertTrue(dao.loans(listOf(CachedLoan.RESERVATION)).isEmpty()) + + dao.replaceWishlist(listOf(WishlistItem(bookId = 5, title = "B").toCached(0), WishlistItem(bookId = 2, title = "A").toCached(1))) + dao.deleteWishlistItem(5) + assertEquals(listOf(2), dao.wishlist().map { it.bookId }) + } +} diff --git a/app/src/test/java/com/pinakes/app/network/AcceptLanguageTest.kt b/app/src/test/java/com/pinakes/app/network/AcceptLanguageTest.kt new file mode 100644 index 0000000..83d7103 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/network/AcceptLanguageTest.kt @@ -0,0 +1,26 @@ +package com.pinakes.app.network + +import com.pinakes.app.data.network.AuthInterceptor +import okhttp3.Request +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** Every API call carries the app language so the server answers labels and errors in it. */ +class AcceptLanguageTest { + private val request = Request.Builder().url("https://library.example/api/v1/health").build() + + @Test fun addsTheAppLanguage() { + assertEquals("it-IT", AuthInterceptor.withLanguage(request, "it-IT").header("Accept-Language")) + } + + @Test fun keepsAnExplicitHeader() { + val explicit = request.newBuilder().header("Accept-Language", "de").build() + assertEquals("de", AuthInterceptor.withLanguage(explicit, "it-IT").header("Accept-Language")) + } + + @Test fun sendsNothingForAnUndefinedLanguage() { + assertNull(AuthInterceptor.withLanguage(request, "und").header("Accept-Language")) + assertNull(AuthInterceptor.withLanguage(request, "").header("Accept-Language")) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/ArchiveFormStateTest.kt b/app/src/test/java/com/pinakes/app/staff/ArchiveFormStateTest.kt new file mode 100644 index 0000000..7504e21 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/ArchiveFormStateTest.kt @@ -0,0 +1,87 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.IdTitle +import com.pinakes.app.data.model.StaffArchiveDetail +import com.pinakes.app.data.model.StaffOffer +import com.pinakes.app.data.staff.ArchiveField +import com.pinakes.app.data.staff.ArchiveForm +import com.pinakes.app.data.staff.AuthorityForm +import com.pinakes.app.ui.screens.staff.plugins.OfferActions +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The archive and authority forms mirror the plugin's payload and rules; offer actions per status. */ +class ArchiveFormStateTest { + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + + @Test fun newFormUsesThePluginDefaultsAndNeedsTheRequiredFields() { + val form = ArchiveForm.new() + assertEquals("PINAKES", form["institution_code"]) + assertEquals("ita", form["language_codes"]) + assertEquals(setOf(ArchiveField.REFERENCE_CODE, ArchiveField.CONSTRUCTED_TITLE), form.errors()) + val ok = form.with("reference_code", "IT-1").with("constructed_title", "Fondo Rossi") + assertTrue(ok.errors().isEmpty()) + } + + @Test fun yearsMustBeNumbersInOrder() { + val base = ArchiveForm.new().with("reference_code", "R").with("constructed_title", "T") + assertEquals(setOf(ArchiveField.DATE_START), base.with("date_start", "1920s").errors()) + assertEquals(setOf(ArchiveField.DATE_END), base.with("date_start", "1950").with("date_end", "1940").errors()) + assertTrue(base.with("date_start", "1940").with("date_end", "1950").errors().isEmpty()) + assertEquals(setOf(ArchiveField.PARENT_ID), base.with("parent_id", "abc").errors()) + } + + @Test fun jsonSendsEveryKeyWithNumbersAndNulls() { + val body = ArchiveForm.new().with("reference_code", " IT-1 ").with("constructed_title", "T").with("date_start", "1920").copy(published = false).toJson() + assertEquals(ArchiveForm.TEXT_KEYS.size + ArchiveForm.INT_KEYS.size + 1, body.size) + assertEquals("IT-1", body["reference_code"]!!.jsonPrimitive.content) + assertEquals(1920, body["date_start"]!!.jsonPrimitive.int) + assertEquals(JsonNull, body["date_end"]) + assertEquals(JsonPrimitive(""), body["extent"]) + assertFalse(body["published"]!!.jsonPrimitive.boolean) + } + + @Test fun formReadsTheDetailColumns() { + val detail = json.decodeFromString( + """{"id":3,"reference_code":"IT-1","level":"series","published":false, + "fields":{"reference_code":"IT-1","level":"series","constructed_title":"Lettere","date_start":1920,"date_end":null,"extent":"2 buste"}}""" + ) + val form = ArchiveForm.from(detail) + assertEquals("Lettere", form["constructed_title"]) + assertEquals("1920", form["date_start"]) + assertEquals("", form["date_end"]) + assertFalse(form.published) + assertTrue(form.errors().isEmpty()) + } + + @Test fun authorityFormNeedsTypeAndName() { + assertFalse(AuthorityForm().valid) + val a = AuthorityForm().with("authorised_form", "Rossi, Mario") + assertTrue(a.valid) + assertEquals(AuthorityForm.KEYS.size, a.toJson().size) + assertEquals("person", a.toJson()["type"]!!.jsonPrimitive.content) + } + + @Test fun offerActionsByStatus() { + val pending = StaffOffer(id = 1, status = "pending") + assertTrue(OfferActions.canAccept(pending) && OfferActions.canReject(pending) && OfferActions.canReceive(pending)) + val accepted = pending.copy(status = "accepted") + assertFalse(OfferActions.canAccept(accepted)) + assertTrue(OfferActions.canReceive(accepted)) + listOf("received", "rejected").forEach { s -> + val o = pending.copy(status = s) + assertFalse(OfferActions.canAccept(o) || OfferActions.canReject(o) || OfferActions.canReceive(o)) + } + assertTrue(OfferActions.needsBook(pending)) + assertFalse(OfferActions.needsBook(pending.copy(book = IdTitle(5, "Orlando")))) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/CalendarLinksTest.kt b/app/src/test/java/com/pinakes/app/staff/CalendarLinksTest.kt new file mode 100644 index 0000000..8443834 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/CalendarLinksTest.kt @@ -0,0 +1,47 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.calendar.CalendarLinks +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CalendarLinksTest { + + private val feed = "https://lib.example.org/api/v1/calendar/feed/" + "a".repeat(64) + ".ics" + + @Test fun httpsBecomesWebcal() { + assertEquals("webcal://lib.example.org/api/v1/calendar/feed/" + "a".repeat(64) + ".ics", CalendarLinks.toWebcal(feed)) + } + + @Test fun httpLoopbackBecomesWebcalToo() { + assertEquals("webcal://10.0.2.2:8081/x.ics", CalendarLinks.toWebcal("http://10.0.2.2:8081/x.ics")) + } + + @Test fun schemeIsCaseInsensitiveAndOtherInputUnchanged() { + assertEquals("webcal://h/x.ics", CalendarLinks.toWebcal("HTTPS://h/x.ics")) + assertEquals("ftp://h/x", CalendarLinks.toWebcal("ftp://h/x")) + } + + @Test fun webcalBackToHttps() { + assertEquals("https://h/x.ics", CalendarLinks.toHttps("webcal://h/x.ics")) + assertEquals("https://h/x.ics", CalendarLinks.toHttps("https://h/x.ics")) + } + + @Test fun subscribeUrlPrefersServerWebcal() { + assertEquals("webcal://srv/y.ics", CalendarLinks.subscribeUrl(feed, "webcal://srv/y.ics")) + assertEquals(CalendarLinks.toWebcal(feed), CalendarLinks.subscribeUrl(feed, null)) + assertEquals(CalendarLinks.toWebcal(feed), CalendarLinks.subscribeUrl(feed, " ")) + assertNull(CalendarLinks.subscribeUrl(null, null)) + } + + @Test fun maskHidesTheTokenButKeepsHostAndTail() { + val token = "0123456789abcdef".repeat(4) + val masked = CalendarLinks.mask("https://lib.example.org/api/v1/calendar/feed/$token.ics") + assertEquals("lib.example.org/…abcdef.ics", masked) + assertEquals(false, masked.contains(token)) + } + + @Test fun maskOfHostOnlyUrl() { + assertEquals("h", CalendarLinks.mask("https://h")) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/ConnectivityStateTest.kt b/app/src/test/java/com/pinakes/app/staff/ConnectivityStateTest.kt new file mode 100644 index 0000000..13c9c40 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/ConnectivityStateTest.kt @@ -0,0 +1,60 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes +import com.pinakes.app.ui.common.isOffline +import com.pinakes.app.ui.common.serverMessage +import com.pinakes.app.ui.screens.detail.BookDetailUiState +import com.pinakes.app.ui.screens.home.HomeUiState +import com.pinakes.app.ui.screens.library.LibraryUiState +import com.pinakes.app.ui.screens.search.SearchUiState +import com.pinakes.app.ui.screens.wishlist.WishlistUiState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Losing the connection after an online load hides loan affordances and current availability + * at once; the raw network exception text never becomes a UI message. + */ +class ConnectivityStateTest { + + @Test fun homeShelfAvailabilityIsCurrentOnlyOnlineAndFromTheServer() { + assertTrue(HomeUiState(connected = true, fromCache = false).availabilityCurrent) + assertFalse(HomeUiState(connected = false, fromCache = false).availabilityCurrent) + assertFalse(HomeUiState(connected = true, fromCache = true).availabilityCurrent) + } + + @Test fun searchResultsLoseAvailabilityWhenDisconnectedOrCached() { + assertTrue(SearchUiState(connected = true).availabilityCurrent) + assertFalse(SearchUiState(connected = false).availabilityCurrent) + assertFalse(SearchUiState(offline = true).availabilityCurrent) + } + + @Test fun bookDetailIsOfflineWhenCachedOrDisconnected() { + assertFalse(BookDetailUiState(connected = true).offlineNow) + assertTrue(BookDetailUiState(connected = false).offlineNow) + assertTrue(BookDetailUiState(offline = true).offlineNow) + } + + @Test fun libraryActionsDisappearOffline() { + assertTrue(LibraryUiState(connected = true).showActions) + assertFalse(LibraryUiState(connected = false).showActions) + assertFalse(LibraryUiState(offline = true).showActions) + } + + @Test fun wishlistIsOfflineWhenCachedOrDisconnected() { + assertFalse(WishlistUiState().offlineNow) + assertTrue(WishlistUiState(connected = false).offlineNow) + } + + @Test fun networkExceptionTextIsNeverAServerMessage() { + val offline = ApiResult.Failure(ErrorCodes.NETWORK, "Failed to connect to /10.0.2.2:8081") + assertTrue(offline.isOffline) + assertNull(offline.serverMessage()) + assertEquals("Titolo obbligatorio", ApiResult.Failure("validation_error", "Titolo obbligatorio", 422).serverMessage()) + assertNull(ApiResult.Failure("validation_error", " ", 422).serverMessage()) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/CopyCodeCodecTest.kt b/app/src/test/java/com/pinakes/app/staff/CopyCodeCodecTest.kt new file mode 100644 index 0000000..fab5e08 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/CopyCodeCodecTest.kt @@ -0,0 +1,42 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.repository.StaffFilesRepository +import com.pinakes.app.data.staff.CopyCodeCodec +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Copy codes are sent the way the server stores them; export file names are safe to share. */ +class CopyCodeCodecTest { + + @Test fun trimsAndStripsControlCharacters() { + assertEquals("INV-0011", CopyCodeCodec.normalize(" INV-0011\n")) + assertEquals("AB12", CopyCodeCodec.normalize("A\u0000B\u001D1\t2")) + assertEquals("", CopyCodeCodec.normalize(" \r\n ")) + assertFalse(CopyCodeCodec.isValid("\u0007")) + assertTrue(CopyCodeCodec.isValid("x")) + } + + @Test fun capsAtOneHundredCharacters() { + assertEquals(100, CopyCodeCodec.normalize("x".repeat(150)).length) + } + + @Test fun recognisesBookIdentifiers() { + assertTrue(CopyCodeCodec.looksLikeBookIdentifier("9788804668237")) + assertTrue(CopyCodeCodec.looksLikeBookIdentifier("9791234567896")) + assertFalse(CopyCodeCodec.looksLikeBookIdentifier("INV-0011")) + assertFalse(CopyCodeCodec.looksLikeBookIdentifier("4006381333931")) + } + + @Test fun exportFileNames() { + assertEquals("prestito_7_20261014.pdf", StaffFilesRepository.filenameFrom("""attachment; filename="prestito_7_20261014.pdf"""")) + assertEquals("labels.pdf", StaffFilesRepository.filenameFrom("inline; filename=labels.pdf")) + assertEquals("errori è.csv", StaffFilesRepository.filenameFrom("attachment; filename*=UTF-8''errori%20%C3%A8.csv")) + assertNull(StaffFilesRepository.filenameFrom(null)) + assertEquals("passwd", StaffFilesRepository.safeName("../../etc/passwd")) + assertEquals("errori_.csv", StaffFilesRepository.safeName("errori è.csv").replace("__", "_")) + assertEquals("export", StaffFilesRepository.safeName("...")) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/EanAddOnDecodeTest.kt b/app/src/test/java/com/pinakes/app/staff/EanAddOnDecodeTest.kt new file mode 100644 index 0000000..3f855a3 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/EanAddOnDecodeTest.kt @@ -0,0 +1,32 @@ +package com.pinakes.app.staff + +import com.google.zxing.PlanarYUVLuminanceSource +import com.pinakes.app.ui.screens.staff.scan.BarcodeAnalyzer +import java.io.DataInputStream +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * A periodical issue's barcode is the masthead EAN-13 (977…) plus a 2-digit add-on, the issue + * number. The periodicals desk must keep the add-on from the camera or `scan-lookup` can only + * match the masthead; the book scanner must drop it (on an ISBN the 5-digit add-on is a price). + */ +class EanAddOnDecodeTest { + + /** The label as a camera hands it over: a luminance (Y) plane, `width` and `height` first. */ + private fun source(): PlanarYUVLuminanceSource { + val input = DataInputStream(requireNotNull(javaClass.getResourceAsStream("/barcodes/periodical-issue-addon.y"))) + val width = input.readInt() + val height = input.readInt() + val y = ByteArray(width * height).also(input::readFully) + return PlanarYUVLuminanceSource(y, width, height, 0, 0, width, height, false) + } + + @Test fun periodicalDeskKeepsTheIssueAddOn() { + assertEquals("977123456700305", BarcodeAnalyzer.decode(source(), BarcodeAnalyzer.ISBN_FORMATS, appendEanAddOn = true)) + } + + @Test fun bookScannerReadsOnlyTheThirteenDigits() { + assertEquals("9771234567003", BarcodeAnalyzer.decode(source(), BarcodeAnalyzer.ISBN_FORMATS, appendEanAddOn = false)) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/ImportProgressTest.kt b/app/src/test/java/com/pinakes/app/staff/ImportProgressTest.kt new file mode 100644 index 0000000..59e4fce --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/ImportProgressTest.kt @@ -0,0 +1,56 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.ImportJob +import com.pinakes.app.data.model.Meta +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.staff.ChunkStep +import com.pinakes.app.data.staff.ImportProgress +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The resumable import: chunks start where the server is, out-of-order and busy answers recover. */ +class ImportProgressTest { + + private fun failure(code: String, processed: Int? = null) = + ApiResult.Failure(code, "x", 409, meta = Meta(processed = processed), fromEnvelope = true) + + @Test fun chunksStartAtTheServersProcessedCount() { + assertEquals(ChunkStep.Next(0), ImportProgress.next(ImportJob(totalRows = 30))) + assertEquals(ChunkStep.Next(20), ImportProgress.next(ImportJob(totalRows = 30, processed = 20, status = "running"))) + assertEquals(ChunkStep.Done, ImportProgress.next(ImportJob(totalRows = 30, processed = 30, complete = true))) + listOf("completed", "failed", "cancelled").forEach { assertEquals(ChunkStep.Done, ImportProgress.next(ImportJob(status = it))) } + } + + @Test fun outOfOrderContinuesFromTheServer() { + assertEquals(ChunkStep.Next(40), ImportProgress.afterFailure(failure("out_of_order", 40), start = 30, busyAttempts = 0)) + assertTrue(ImportProgress.afterFailure(failure("out_of_order"), 30, 0) is ChunkStep.Stop) + } + + @Test fun busyWaitsAFewTimesThenStops() { + assertEquals(ChunkStep.Wait(10), ImportProgress.afterFailure(failure("job_busy"), 10, busyAttempts = 1)) + assertTrue(ImportProgress.afterFailure(failure("job_busy"), 10, busyAttempts = ImportProgress.BUSY_RETRIES) is ChunkStep.Stop) + } + + @Test fun goneOfflineOrRefusedStops() { + listOf("gone", "network", "forbidden", "not_found").forEach { + assertTrue(it, ImportProgress.afterFailure(failure(it), 0, 0) is ChunkStep.Stop) + } + } + + @Test fun percentIsClamped() { + assertEquals(0, ImportProgress.percent(ImportJob(totalRows = 0))) + assertEquals(100, ImportProgress.percent(ImportJob(totalRows = 0, complete = true))) + assertEquals(33, ImportProgress.percent(ImportJob(totalRows = 30, processed = 10))) + assertEquals(100, ImportProgress.percent(ImportJob(totalRows = 30, processed = 45))) + } + + @Test fun fileExtensions() { + assertTrue(ImportProgress.acceptsFileName("libri.CSV", librarything = false)) + assertFalse(ImportProgress.acceptsFileName("libri.tsv", librarything = false)) + assertTrue(ImportProgress.acceptsFileName("export.tsv", librarything = true)) + assertTrue(ImportProgress.acceptsFileName("export.txt", librarything = true)) + assertFalse(ImportProgress.acceptsFileName("export.xlsx", librarything = true)) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/IsbnCodecTest.kt b/app/src/test/java/com/pinakes/app/staff/IsbnCodecTest.kt new file mode 100644 index 0000000..a24898b --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/IsbnCodecTest.kt @@ -0,0 +1,87 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.staff.Identifier +import com.pinakes.app.data.staff.IsbnCodec +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class IsbnCodecTest { + + @Test fun ean13ChecksumValidAndInvalid() { + assertTrue(IsbnCodec.checkEan13("9788804668237")) + assertFalse(IsbnCodec.checkEan13("9788804668238")) + assertTrue(IsbnCodec.checkEan13("4006381333931")) // non-book EAN + assertFalse(IsbnCodec.checkEan13("978880466823")) // 12 digits + assertFalse(IsbnCodec.checkEan13("97888046682A7")) + } + + @Test fun scanOfBooklandBecomesIsbn13WithIsbn10() { + val id = IsbnCodec.fromScan("9788804668237") + assertEquals(Identifier.Isbn13("9788804668237", "8804668237"), id) + assertEquals("9788804668237", id?.lookupCode) + } + + @Test fun scanOf979HasNoIsbn10() { + val id = IsbnCodec.fromScan("9791032305690") as Identifier.Isbn13 + assertNull(id.isbn10) + } + + @Test fun scanOfNonBookEanIsEan() { + assertEquals(Identifier.Ean("4006381333931"), IsbnCodec.fromScan("4006381333931")) + } + + @Test fun scanRejectsBadChecksumAndPartialReads() { + assertNull(IsbnCodec.fromScan("9788804668238")) + assertNull(IsbnCodec.fromScan("88046682")) + assertNull(IsbnCodec.fromScan(null)) + assertNull(IsbnCodec.fromScan("978-8804668237")) // scans are digits only + } + + @Test fun manualIsbn10WithXConvertsTo13AndKeepsBoth() { + val id = IsbnCodec.fromManual("0-8044-2957-X") as Identifier.Isbn13 + assertEquals("080442957X", id.isbn10) + assertEquals("9780804429573", id.isbn13) + assertTrue(IsbnCodec.checkEan13(id.isbn13)) + } + + @Test fun manualLowercaseXIsAccepted() { + assertEquals("080442957X", (IsbnCodec.fromManual("080442957x") as Identifier.Isbn13).isbn10) + } + + @Test fun manualAcceptsDashesAndSpaces() { + assertEquals(Identifier.Isbn13("9788804668237", "8804668237"), IsbnCodec.fromManual(" 978-88-04 66823-7 ")) + } + + @Test fun manualAcceptsEan() { + assertEquals(Identifier.Ean("4006381333931"), IsbnCodec.fromManual("4006381333931")) + } + + @Test fun manualRejectsGarbage() { + assertNull(IsbnCodec.fromManual("")) + assertNull(IsbnCodec.fromManual("hello")) + assertNull(IsbnCodec.fromManual("0804429571")) // wrong ISBN-10 check digit + assertNull(IsbnCodec.fromManual("X804429573")) // X only allowed last + assertNull(IsbnCodec.fromManual("97888046682370")) // 14 digits + } + + @Test fun isbn10And13ConversionsAreInverse() { + assertEquals("9788804668237", IsbnCodec.isbn10To13("8804668237")) + assertEquals("8804668237", IsbnCodec.isbn13To10("9788804668237")) + assertNull(IsbnCodec.isbn13To10("9791032305690")) + } + + @Test fun formValidatorsAllowBlankAndRejectWrongCodes() { + assertTrue(IsbnCodec.isValidIsbn13OrBlank("")) + assertTrue(IsbnCodec.isValidIsbn13OrBlank("978-88-04-66823-7")) + assertFalse(IsbnCodec.isValidIsbn13OrBlank("4006381333931")) // valid EAN, not an ISBN + assertTrue(IsbnCodec.isValidIsbn10OrBlank("080442957X")) + assertFalse(IsbnCodec.isValidIsbn10OrBlank("0804429571")) + assertTrue(IsbnCodec.isValidEanOrBlank("4006381333931")) + assertTrue(IsbnCodec.isValidIssnOrBlank("0317-8471")) + assertTrue(IsbnCodec.isValidIssnOrBlank("2049-3630")) + assertFalse(IsbnCodec.isValidIssnOrBlank("0317-8472")) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/IssueScanResolverTest.kt b/app/src/test/java/com/pinakes/app/staff/IssueScanResolverTest.kt new file mode 100644 index 0000000..96fb88c --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/IssueScanResolverTest.kt @@ -0,0 +1,77 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.staff.IssueScanResolver +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class IssueScanResolverTest { + + private val masthead = "9771234567003" + private val issue = "977123456700305" + + @Test fun twoBareReadsWaitForTheAddOnInsteadOfLookingUpTheMasthead() { + val r = IssueScanResolver() + assertNull(r.offer(masthead, 0)) + assertNull(r.offer(masthead, 40)) + assertTrue(r.holding) + // The frame that resolves the add-on arrives within the hold: the issue wins at once. + assertEquals(issue, r.offer(issue, 120)) + assertFalse(r.holding) + assertNull(r.flush(2_000)) + } + + @Test fun aBareReadAfterAnAddOnReadCountsAsTheIssue() { + val r = IssueScanResolver() + assertNull(r.offer(issue, 0)) + // Blurred frame: only the 13 digits, but it confirms the issue read just seen. + assertEquals(issue, r.offer(masthead, 40)) + } + + @Test fun aMastheadWithNoAddOnIsStillLookedUpAfterTheHold() { + val r = IssueScanResolver(holdMs = 800) + assertNull(r.offer(masthead, 0)) + assertNull(r.offer(masthead, 40)) + // The magazine stays in view: more bare frames arrive during the hold. + assertNull(r.offer(masthead, 80)) + assertNull(r.offer(masthead, 120)) + assertTrue(r.holding) + assertNull(r.flush(500)) + assertEquals(masthead, r.flush(840)) + assertFalse(r.holding) + } + + @Test fun aBookEanIsNotHeld() { + val r = IssueScanResolver() + val isbn = "9788804668237" + assertNull(r.offer(isbn, 0)) + assertEquals(isbn, r.offer(isbn, 40)) + assertFalse(r.holding) + } + + @Test fun theIssueJustHandledIsNotLookedUpAgainAfterResume() { + val r = IssueScanResolver() + r.offer(issue, 0) + assertEquals(issue, r.offer(issue, 40)) + // Received or dismissed: the desk resumes with the magazine still under the camera. + r.debouncer.resume(1_000) + assertNull(r.offer(masthead, 1_040)) + assertNull(r.offer(masthead, 1_080)) + assertFalse(r.holding) + assertNull(r.offer(issue, 1_120)) + assertNull(r.offer(issue, 1_160)) + assertNull(r.flush(2_500)) + // Once the window has passed the same issue can be scanned again. + assertNull(r.offer(issue, 4_100)) + assertEquals(issue, r.offer(issue, 4_140)) + } + + @Test fun nothingIsAcceptedWhilePaused() { + val r = IssueScanResolver() + r.debouncer.pause() + assertNull(r.offer(issue, 0)) + assertNull(r.offer(issue, 40)) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/LoanActionsTest.kt b/app/src/test/java/com/pinakes/app/staff/LoanActionsTest.kt new file mode 100644 index 0000000..32ffc67 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/LoanActionsTest.kt @@ -0,0 +1,53 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.StaffLoan +import com.pinakes.app.data.staff.LoanAction +import com.pinakes.app.data.staff.LoanActionUi +import com.pinakes.app.data.staff.LoanActions +import com.pinakes.app.data.staff.ReturnOutcome +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Which loan actions show, and how each opens. The server stays the authority. */ +class LoanActionsTest { + + @Test fun everyWireValueRoundTrips() { + LoanAction.entries.forEach { assertEquals(it, LoanAction.fromWire(it.wire)) } + assertEquals(LoanAction.CONFIRM_PICKUP, LoanAction.fromWire(" Confirm_Pickup ")) + assertNull(LoanAction.fromWire("teleport")) + } + + @Test fun unknownAndDuplicateActionsAreDropped() { + val loan = StaffLoan(actions = listOf("approve", "approve", "unknown", "reject")) + assertEquals(listOf(LoanAction.APPROVE, LoanAction.REJECT), LoanActions.of(loan)) + } + + @Test fun presentation() { + assertEquals(LoanActionUi.CONFIRM, LoanActions.ui(LoanAction.APPROVE)) + assertEquals(LoanActionUi.REASON, LoanActions.ui(LoanAction.REJECT)) + assertEquals(LoanActionUi.REASON, LoanActions.ui(LoanAction.CANCEL_PICKUP)) + assertEquals(LoanActionUi.RETURN_SHEET, LoanActions.ui(LoanAction.RETURN)) + assertEquals(LoanActionUi.EDIT_SHEET, LoanActions.ui(LoanAction.EDIT)) + assertEquals(LoanActionUi.DOWNLOAD, LoanActions.ui(LoanAction.RECEIPT)) + assertEquals(LoanActionUi.IMMEDIATE, LoanActions.ui(LoanAction.RECALL)) + assertEquals(LoanActionUi.IMMEDIATE, LoanActions.ui(LoanAction.EMAIL_RECEIPT)) + } + + @Test fun onlyOutstandingLoansAreBulkSelectable() { + assertTrue(LoanActions.isBulkSelectable(StaffLoan(status = "in_corso"))) + assertTrue(LoanActions.isBulkSelectable(StaffLoan(status = "in_ritardo"))) + listOf("pendente", "da_ritirare", "prenotato", "restituito", "perso").forEach { + assertFalse(it, LoanActions.isBulkSelectable(StaffLoan(status = it))) + } + } + + @Test fun returnOutcomes() { + assertEquals(ReturnOutcome.RETURNED, ReturnOutcome.fromWire(null)) + assertEquals(ReturnOutcome.DAMAGED, ReturnOutcome.fromWire("danneggiato")) + assertEquals(setOf(ReturnOutcome.LOST, ReturnOutcome.DAMAGED), ReturnOutcome.entries.filter { it.allowsPenalty }.toSet()) + assertEquals(listOf("restituito", "manutenzione", "in_restauro", "perso", "danneggiato"), ReturnOutcome.entries.map { it.wire }) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/OfflinePolicyTest.kt b/app/src/test/java/com/pinakes/app/staff/OfflinePolicyTest.kt new file mode 100644 index 0000000..7a03d8b --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/OfflinePolicyTest.kt @@ -0,0 +1,28 @@ +package com.pinakes.app.staff + +import com.pinakes.app.ui.common.OfflinePolicy +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Offline, availability cannot be guaranteed: loan affordances are hidden, never disabled. */ +class OfflinePolicyTest { + + @Test fun onlineWithLoansShowsLoanActions() { + assertTrue(OfflinePolicy.showLoanActions(canBorrow = true, offline = false)) + } + + @Test fun offlineNeverShowsLoanActions() { + assertFalse(OfflinePolicy.showLoanActions(canBorrow = true, offline = true)) + assertFalse(OfflinePolicy.showLoanActions(canBorrow = false, offline = true)) + } + + @Test fun withoutLoansFeatureNoLoanActions() { + assertFalse(OfflinePolicy.showLoanActions(canBorrow = false, offline = false)) + } + + @Test fun cachedAvailabilityIsNeverPresentedAsCurrent() { + assertTrue(OfflinePolicy.showCurrentAvailability(offline = false)) + assertFalse(OfflinePolicy.showCurrentAvailability(offline = true)) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/PenaltyValidationTest.kt b/app/src/test/java/com/pinakes/app/staff/PenaltyValidationTest.kt new file mode 100644 index 0000000..e4852cd --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/PenaltyValidationTest.kt @@ -0,0 +1,37 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.staff.LoanActions +import com.pinakes.app.data.staff.PenaltyInput +import com.pinakes.app.data.staff.ReturnOutcome +import org.junit.Assert.assertEquals +import org.junit.Test + +/** The return sheet's penalty field: decimal comma or point, two decimals, 0..99999999.99, lost/damaged only. */ +class PenaltyValidationTest { + + private fun parse(raw: String, outcome: ReturnOutcome = ReturnOutcome.DAMAGED) = LoanActions.parsePenalty(raw, outcome) + + @Test fun blankIsNoPenalty() { + assertEquals(PenaltyInput.None, parse("")) + assertEquals(PenaltyInput.None, parse(" ", ReturnOutcome.RETURNED)) + } + + @Test fun acceptsPointAndComma() { + assertEquals(PenaltyInput.Amount(12.5), parse("12.5")) + assertEquals(PenaltyInput.Amount(12.5), parse("12,50")) + assertEquals(PenaltyInput.Amount(0.0), parse("0")) + assertEquals(PenaltyInput.Amount(99_999_999.99), parse("99999999.99", ReturnOutcome.LOST)) + } + + @Test fun rejectsMalformedOrOutOfRange() { + listOf("-1", "1.234", "12a", "1e3", "100000000", "1.2.3", ",5", "5,").forEach { + assertEquals(it, PenaltyInput.Invalid, parse(it)) + } + } + + @Test fun onlyLostOrDamagedCarryAPenalty() { + listOf(ReturnOutcome.RETURNED, ReturnOutcome.MAINTENANCE, ReturnOutcome.RESTORATION).forEach { + assertEquals(PenaltyInput.Invalid, parse("5", it)) + } + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/ProfileFieldVisibilityTest.kt b/app/src/test/java/com/pinakes/app/staff/ProfileFieldVisibilityTest.kt new file mode 100644 index 0000000..f0c33ca --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/ProfileFieldVisibilityTest.kt @@ -0,0 +1,62 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.ProfileFieldRule +import com.pinakes.app.data.model.RegistrationFieldsPayload +import com.pinakes.app.data.model.UserProfile +import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.screens.profile.ProfileFieldVisibility +import com.pinakes.app.ui.screens.profile.ProfileFieldVisibility.GENDER +import com.pinakes.app.ui.screens.profile.ProfileFieldVisibility.TAX_CODE +import com.pinakes.app.ui.screens.profile.ProfileUiState +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 admin can hide gender and tax code; older servers say nothing and keep them visible. */ +class ProfileFieldVisibilityTest { + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + + @Test fun visibleWhenTheServerSaysNothing() { + assertTrue(ProfileFieldVisibility.isVisible(GENDER)) + assertTrue(ProfileFieldVisibility.isVisible(TAX_CODE, emptyList(), emptyMap(), null)) + } + + @Test fun anySourceHidesTheField() { + assertFalse(ProfileFieldVisibility.isVisible(GENDER, hiddenFields = listOf("sesso"))) + assertFalse(ProfileFieldVisibility.isVisible(TAX_CODE, profileFields = mapOf("cod_fiscale" to ProfileFieldRule(visible = false)))) + assertFalse(ProfileFieldVisibility.isVisible(GENDER, healthShows = false)) + // Hiding one field leaves the other alone. + assertTrue(ProfileFieldVisibility.isVisible(TAX_CODE, hiddenFields = listOf("sesso"))) + } + + @Test fun explicitlyVisibleStaysVisible() { + assertTrue(ProfileFieldVisibility.isVisible(GENDER, emptyList(), mapOf("sesso" to ProfileFieldRule(visible = true)), true)) + } + + @Test fun profileStateFollowsMeAndTheSchema() { + val hidden = ProfileUiState(profile = UiState.Success(UserProfile(hiddenFields = listOf("sesso", "cod_fiscale")))) + assertFalse(hidden.showGender) + assertFalse(hidden.showTaxCode) + val schemaHidden = ProfileUiState(profileFields = mapOf("cod_fiscale" to ProfileFieldRule(visible = false))) + assertTrue(schemaHidden.showGender) + assertFalse(schemaHidden.showTaxCode) + assertTrue(ProfileUiState().showGender && ProfileUiState().showTaxCode) + } + + @Test fun payloadsDecodeTheNewKeysAndToleratePreviousServers() { + val me = json.decodeFromString("""{"id":1,"nome":"A","hidden_fields":["sesso","cod_fiscale"]}""") + assertEquals(listOf("sesso", "cod_fiscale"), me.hiddenFields) + assertNull(me.sesso) + val old = json.decodeFromString("""{"id":1,"nome":"A","sesso":"F"}""") + assertTrue(old.hiddenFields.isEmpty()) + val reg = json.decodeFromString( + """{"registration_enabled":true,"profile_fields":{"sesso":{"visible":false,"configurable":true},"data_nascita":{"visible":true,"configurable":false}}}""" + ) + assertEquals(false, reg.profileFields["sesso"]?.visible) + assertTrue(json.decodeFromString("""{"registration_enabled":true}""").profileFields.isEmpty()) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/RecordWritesTest.kt b/app/src/test/java/com/pinakes/app/staff/RecordWritesTest.kt new file mode 100644 index 0000000..f06bbd8 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/RecordWritesTest.kt @@ -0,0 +1,61 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.StaffAuthorWrite +import com.pinakes.app.data.model.StaffPublisherWrite +import com.pinakes.app.ui.screens.staff.RecordForm +import com.pinakes.app.ui.screens.staff.toAuthorWrite +import com.pinakes.app.ui.screens.staff.toPublisherWrite +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Author/publisher saves must be able to clear a field: cleared fields go out as "". */ +class RecordWritesTest { + + // Same configuration as NetworkModule.json: null keys are dropped from requests. + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + + @Test fun clearedAuthorFieldsAreSentAsEmptyStrings() { + val body = json.encodeToJsonElement( + StaffAuthorWrite.serializer(), + RecordForm(id = 3, name = " Italo Calvino ", pseudonym = "", born = "", died = "", website = " ", gndId = ""), + ).jsonObject + assertEquals("Italo Calvino", body.str("name")) + for (key in listOf("pseudonym", "born", "died", "nationality", "biography", "website", "gnd_id")) { + assertEquals("key $key", "", body.str(key)) + } + } + + @Test fun filledAuthorFieldsAreTrimmed() { + val body = json.encodeToJsonElement(StaffAuthorWrite.serializer(), RecordForm(name = "A", born = " 1923-10-15 ")).jsonObject + assertEquals("1923-10-15", body.str("born")) + } + + @Test fun clearedPublisherFieldsAreSentAsEmptyStrings() { + val body = json.encodeToJsonElement(StaffPublisherWrite.serializer(), RecordForm(name = "Einaudi", website = "", phone = "")).jsonObject + assertEquals(setOf("name", "website", "address", "phone", "email"), body.keys) + assertEquals("", body.str("website")) + assertEquals("", body.str("email")) + } + + private fun Json.encodeToJsonElement(serializer: kotlinx.serialization.KSerializer, form: RecordForm) = + encodeToJsonElement(serializer, form.toAuthorWrite()) + + @JvmName("encodePublisher") + private fun Json.encodeToJsonElement(serializer: kotlinx.serialization.KSerializer, form: RecordForm) = + encodeToJsonElement(serializer, form.toPublisherWrite()) + + private fun JsonObject.str(k: String) = this[k]!!.jsonPrimitive.content + + @Test fun aFormThatWasNotLoadedCanNeverBeSubmitted() { + // Placeholder after a failed GET /staff/authors/{id}: saving it would wipe the record. + assertEquals(false, RecordForm(id = 3, name = "Italo Calvino", loaded = false).canSubmit) + assertEquals(true, RecordForm(id = 3, name = "Italo Calvino").canSubmit) + assertEquals(true, RecordForm(name = "New author").canSubmit) + assertEquals(false, RecordForm(name = " ").canSubmit) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/ScanDebouncerTest.kt b/app/src/test/java/com/pinakes/app/staff/ScanDebouncerTest.kt new file mode 100644 index 0000000..63950b8 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/ScanDebouncerTest.kt @@ -0,0 +1,79 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.staff.ScanDebouncer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ScanDebouncerTest { + + private val a = "9788804668237" + private val b = "9780140186475" + + @Test fun needsTwoConsecutiveIdenticalReads() { + val d = ScanDebouncer() + assertNull(d.offer(a, 0)) + assertEquals(a, d.offer(a, 50)) + } + + @Test fun aDifferentCodeInBetweenRestartsTheStreak() { + val d = ScanDebouncer() + assertNull(d.offer(a, 0)) + assertNull(d.offer(b, 10)) + assertNull(d.offer(a, 20)) + assertEquals(a, d.offer(a, 30)) + } + + @Test fun sameCodeIsSuppressedForThreeSeconds() { + val d = ScanDebouncer() + d.offer(a, 0); assertEquals(a, d.offer(a, 10)) + assertNull(d.offer(a, 500)) + assertNull(d.offer(a, 2_999)) + // After the window it needs two fresh reads again. + assertNull(d.offer(a, 3_100)) + assertEquals(a, d.offer(a, 3_200)) + } + + @Test fun anotherCodeIsNotSuppressed() { + val d = ScanDebouncer() + d.offer(a, 0); d.offer(a, 10) + assertNull(d.offer(b, 100)) + assertEquals(b, d.offer(b, 150)) + } + + @Test fun pauseIgnoresEverythingAndResumeStartsFresh() { + val d = ScanDebouncer() + d.pause() + assertNull(d.offer(a, 0)) + assertNull(d.offer(a, 10)) + d.resume() + assertNull(d.offer(a, 20)) // the paused reads did not count + assertEquals(a, d.offer(a, 30)) + } + + @Test fun resetAllowsTheSameCodeImmediately() { + val d = ScanDebouncer() + d.offer(a, 0); d.offer(a, 10) + d.reset() + assertNull(d.offer(a, 20)) + assertEquals(a, d.offer(a, 30)) + } + + @Test fun resumeWithTimeRestartsTheSuppressionWindow() { + val d = ScanDebouncer() + d.offer(a, 0); assertEquals(a, d.offer(a, 10)) + d.pause() + // The sheet stayed open 10 s; the book is still under the camera when it closes. + d.resume(nowMs = 10_000) + assertNull(d.offer(a, 10_100)) + assertNull(d.offer(a, 10_200)) + assertNull(d.offer(a, 12_900)) + // After the restarted window it can be scanned again (two fresh reads). + assertNull(d.offer(a, 13_100)) + assertEquals(a, d.offer(a, 13_200)) + // Other codes are never held back. + d.pause(); d.resume(nowMs = 20_000) + assertNull(d.offer(b, 20_050)) + assertEquals(b, d.offer(b, 20_100)) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/SearchTextTest.kt b/app/src/test/java/com/pinakes/app/staff/SearchTextTest.kt new file mode 100644 index 0000000..8f892a5 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/SearchTextTest.kt @@ -0,0 +1,37 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.local.SearchText +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SearchTextTest { + + @Test fun foldRemovesAccentsAndCase() { + assertEquals("citta perche", SearchText.fold("Città Perché")) + assertEquals("uber kastner", SearchText.fold("Über Kästner")) + assertEquals("ærø", SearchText.fold("ÆRØ")) + assertEquals("", SearchText.fold(null)) + } + + @Test fun keyHoldsTextAndCompactIdentifiers() { + val key = SearchText.key("La Città", null, "Calvino", "Einaudi", "978-88-04-66823-7", null, null) + assertTrue(key.contains("la citta")) + assertTrue(key.contains("calvino")) + assertTrue(key.contains("9788804668237")) + } + + @Test fun queryPatternEscapesLikeMetacharacters() { + assertEquals("100\\%", SearchText.queryPattern("100%")) + assertEquals("a\\_b", SearchText.queryPattern("a_b")) + assertEquals("c:\\\\x", SearchText.queryPattern("C:\\X")) + } + + @Test fun identifierLikeQueriesLoseSeparators() { + assertEquals("9788804668237", SearchText.queryPattern(" 978-88-04-66823-7 ")) + assertEquals("080442957x", SearchText.queryPattern("0-8044-2957-X")) + // Short numbers and mixed text keep their spaces and dashes. + assertEquals("1-2", SearchText.queryPattern("1-2")) + assertEquals("harry potter 7", SearchText.queryPattern("Harry Potter 7")) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/SeriesTreeTest.kt b/app/src/test/java/com/pinakes/app/staff/SeriesTreeTest.kt new file mode 100644 index 0000000..e8a4cb9 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/SeriesTreeTest.kt @@ -0,0 +1,79 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.StaffSeries +import com.pinakes.app.data.staff.SeriesTree +import com.pinakes.app.ui.screens.staff.content.LabelForm +import com.pinakes.app.ui.screens.staff.content.SeriesForm +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The series tree rows, merge-target exclusion, and the series/label forms. */ +class SeriesTreeTest { + + private val tree = listOf( + StaffSeries(id = 1, name = "Discworld", children = listOf( + StaffSeries(id = 2, name = "Rincewind", children = listOf(StaffSeries(id = 3, name = "Luggage"))), + StaffSeries(id = 4, name = "Watch"), + )), + StaffSeries(id = 5, name = "Gialli"), + ) + + @Test fun collapsedShowsRootsOnly() { + val rows = SeriesTree.rows(tree, expanded = emptySet()) + assertEquals(listOf(1, 5), rows.map { it.series.id }) + assertTrue(rows.first().hasChildren) + assertFalse(rows.first().expanded) + } + + @Test fun expandingRevealsChildrenWithDepth() { + val rows = SeriesTree.rows(tree, expanded = setOf(1)) + assertEquals(listOf(1, 2, 4, 5), rows.map { it.series.id }) + assertEquals(listOf(0, 1, 1, 0), rows.map { it.depth }) + assertEquals(listOf(1, 2, 3, 4, 5), SeriesTree.rows(tree, setOf(1, 2)).map { it.series.id }) + } + + @Test fun searchKeepsAncestorsOfDeepMatches() { + val rows = SeriesTree.rows(tree, expanded = emptySet(), query = "lugg") + assertEquals(listOf(1, 2, 3), rows.map { it.series.id }) + assertEquals(listOf(5), SeriesTree.rows(tree, emptySet(), "GIALLI").map { it.series.id }) + assertTrue(SeriesTree.rows(tree, emptySet(), "nothing").isEmpty()) + } + + @Test fun flattenAndSubtree() { + assertEquals(listOf(1, 2, 3, 4, 5), SeriesTree.flatten(tree).map { it.id }) + assertEquals(setOf(2, 3), SeriesTree.subtreeIds(tree, 2)) + assertEquals(setOf(99), SeriesTree.subtreeIds(tree, 99)) + } + + @Test fun seriesFormValidationAndWrite() { + assertFalse(SeriesForm(name = " ").valid) + assertFalse(SeriesForm(name = "x".repeat(101)).valid) + assertFalse(SeriesForm(name = "A", cycleOrder = "70000").valid) + val body = SeriesForm(name = " Discworld ", cycleOrder = "3", parentName = "", complete = true).toJson() + assertEquals("Discworld", body["name"]!!.jsonPrimitive.content) + assertEquals(3, body["cycle_order"]!!.jsonPrimitive.int) + assertEquals("", body["parent_name"]!!.jsonPrimitive.content) + assertEquals(true, body["complete"]!!.jsonPrimitive.boolean) + // A cleared order is sent as an explicit null, so the server clears it instead of keeping it. + val cleared = SeriesForm(name = "Discworld", cycleOrder = "").toJson() + assertTrue(cleared.containsKey("cycle_order")) + assertEquals(JsonNull, cleared["cycle_order"]) + } + + @Test fun labelFormRanges() { + val ok = LabelForm(width = "70", height = "36,5", padding = "") + assertTrue(ok.valid) + assertEquals(36.5, ok.toSettings().height!!, 0.0) + assertEquals(null, ok.toSettings().padding) + assertFalse(LabelForm(width = "9", height = "30").valid) + assertFalse(LabelForm(width = "50", height = "101").valid) + assertFalse(LabelForm(width = "50", height = "30", padding = "25").valid) + assertEquals("70", LabelForm.from(ok.toSettings().copy(width = 70.0)).width) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/StaffAccessTest.kt b/app/src/test/java/com/pinakes/app/staff/StaffAccessTest.kt new file mode 100644 index 0000000..cdd921e --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/StaffAccessTest.kt @@ -0,0 +1,28 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.staff.StaffAccess +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StaffAccessTest { + + @Test fun adminAndStaffAreStaff() { + assertTrue(StaffAccess.isStaff("admin")) + assertTrue(StaffAccess.isStaff("staff")) + assertTrue(StaffAccess.isStaff(" Staff ")) + } + + @Test fun readersAndUnknownAreNot() { + assertFalse(StaffAccess.isStaff("utente")) + assertFalse(StaffAccess.isStaff("premium")) + assertFalse(StaffAccess.isStaff("")) + assertFalse(StaffAccess.isStaff(null)) + } + + @Test fun staffAreaNeedsBothRoleAndServerSupport() { + assertTrue(StaffAccess.canManageCatalog("admin", serverSupportsStaff = true)) + assertFalse(StaffAccess.canManageCatalog("admin", serverSupportsStaff = false)) + assertFalse(StaffAccess.canManageCatalog("utente", serverSupportsStaff = true)) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/StaffAreasTest.kt b/app/src/test/java/com/pinakes/app/staff/StaffAreasTest.kt new file mode 100644 index 0000000..6d59933 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/StaffAreasTest.kt @@ -0,0 +1,55 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.staff.StaffAreas +import com.pinakes.app.data.store.InstanceFeatures +import com.pinakes.app.ui.common.OfflinePolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Staff areas = role × `/health` flags × connectivity; circulation disappears offline. */ +class StaffAreasTest { + + private val all = InstanceFeatures( + staffCatalogAvailable = true, staffCirculationAvailable = true, staffCopiesAvailable = true, staffSeriesAvailable = true, + staffMergesAvailable = true, staffLabelsAvailable = true, staffImportsAvailable = true, staffCoversAvailable = true, + staffDesiderataAvailable = true, staffArchivesAvailable = true, staffLibraryThingAvailable = true, + ) + + @Test fun staffOnlineSeesEverythingAdvertised() { + val a = StaffAreas.of("staff", all, online = true) + assertTrue(a.staff && a.circulation && a.copies && a.series && a.merges && a.labels && a.imports && a.covers) + assertTrue(a.desiderata && a.archives && a.libraryThing && a.anyBooksTool && a.anyPlugin) + } + + @Test fun offlineHidesOnlyCirculation() { + val a = StaffAreas.of("admin", all, online = false) + assertFalse(a.circulation) + assertTrue(a.staff && a.series && a.imports && a.archives) + } + + @Test fun borrowersAndUnknownRolesSeeNothing() { + listOf(null, "", "standard", "premium").forEach { role -> + assertEquals(role.toString(), StaffAreas.None, StaffAreas.of(role, all, online = true)) + } + } + + @Test fun withoutTheStaffAreaNothingShows() { + assertEquals(StaffAreas.None, StaffAreas.of("admin", all.copy(staffCatalogAvailable = false), online = true)) + } + + @Test fun eachFlagGatesItsArea() { + val none = StaffAreas.of("admin", InstanceFeatures(staffCatalogAvailable = true), online = true) + assertTrue(none.staff) + assertFalse(none.circulation || none.series || none.imports || none.desiderata || none.archives || none.anyBooksTool || none.anyPlugin) + // LibraryThing is an import kind: no imports area, no LibraryThing either. + assertFalse(StaffAreas.of("admin", all.copy(staffImportsAvailable = false), true).libraryThing) + } + + @Test fun offlinePolicyForCirculation() { + assertTrue(OfflinePolicy.showStaffCirculation(available = true, online = true)) + assertFalse(OfflinePolicy.showStaffCirculation(available = true, online = false)) + assertFalse(OfflinePolicy.showStaffCirculation(available = false, online = true)) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/StaffBookFormStateTest.kt b/app/src/test/java/com/pinakes/app/staff/StaffBookFormStateTest.kt new file mode 100644 index 0000000..83ea47a --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/StaffBookFormStateTest.kt @@ -0,0 +1,209 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.IdName +import com.pinakes.app.data.model.IsbnPreview +import com.pinakes.app.data.model.StaffAuthorRef +import com.pinakes.app.data.model.StaffBook +import com.pinakes.app.data.model.StaffLocation +import com.pinakes.app.data.staff.FormField +import com.pinakes.app.data.staff.Identifier +import com.pinakes.app.data.staff.PickedEntity +import com.pinakes.app.data.staff.StaffBookForm +import com.pinakes.app.data.staff.StaffBookWrite +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StaffBookFormStateTest { + + private val valid = StaffBookForm(title = "Spartito", copies = 1) + + @Test fun titleIsRequired() { + assertTrue(FormField.TITLE in StaffBookForm().validate()) + assertTrue(FormField.TITLE in StaffBookForm(title = " ").validate()) + assertTrue(valid.canSave()) + } + + @Test fun copiesMustBeOneToHundredOnCreateOnly() { + assertTrue(FormField.COPIES in valid.copy(copies = 0).validate()) + assertTrue(FormField.COPIES in valid.copy(copies = 101).validate()) + assertTrue(valid.copy(copies = 100).canSave()) + assertTrue(valid.copy(copies = 0).canSave(isEdit = true)) + } + + @Test fun identifiersAreValidatedButOptional() { + assertTrue(valid.copy(isbn13 = "978-88-04-66823-7").canSave()) + assertFalse(valid.copy(isbn13 = "9788804668238").canSave()) + assertFalse(valid.copy(isbn10 = "0804429571").canSave()) + assertFalse(valid.copy(ean = "123").canSave()) + assertFalse(valid.copy(issn = "0317-8472").canSave()) + assertEquals(setOf(FormField.ISBN13, FormField.EAN), valid.copy(isbn13 = "1", ean = "2").validate()) + } + + @Test fun numbersAreChecked() { + assertFalse(valid.copy(year = "19x9").canSave()) + assertTrue(valid.copy(year = "-500").canSave()) // antiquity + assertFalse(valid.copy(pages = "0").canSave()) + assertFalse(valid.copy(pages = "abc").canSave()) + assertTrue(valid.copy(price = "12,50").canSave()) + assertFalse(valid.copy(price = "12.505").canSave()) + } + + @Test fun createSendsOnlyFilledFields() { + val body = StaffBookWrite.create( + valid.copy( + subtitle = "", isbn13 = "978-88-04-66823-7", year = "2015", pages = "320", copies = 2, + authors = listOf(PickedEntity(4, "Eco"), PickedEntity(0, "New Author")), + publishers = listOf(PickedEntity(0, "Bompiani")), + genre = IdName(3, "Romanzo"), shelfId = 1, levelId = 2, + coverUrl = "https://covers.example.org/x.jpg", authorBio = "Bio", + ) + ) + assertEquals("Spartito", body.str("title")) + assertFalse("subtitle" in body) + assertEquals("9788804668237", body.str("isbn13")) // separators stripped + assertEquals(2015, body.num("year")) + assertEquals(320, body.num("pages")) + assertEquals(2, body.num("copies")) + assertEquals(listOf(4), body.ints("author_ids")) + assertEquals(listOf("New Author"), body.strs("authors_new")) + assertFalse("publisher_ids" in body) + assertEquals(listOf("Bompiani"), body.strs("publishers_new")) + assertEquals(3, body.num("genre_id")) + assertEquals(1, body.num("shelf_id")) + assertEquals(2, body.num("level_id")) + assertEquals(0, body.num("position")) + assertEquals("https://covers.example.org/x.jpg", body.str("cover_url")) + assertEquals("Bio", body["scraped"]!!.jsonObject.str("author_bio")) + } + + @Test fun createWithoutLevelSendsNoLocation() { + val body = StaffBookWrite.create(valid.copy(shelfId = 1, levelId = null)) + assertFalse("shelf_id" in body) + assertFalse("position" in body) + } + + private val original = StaffBook( + id = 10, title = "Orlando", subtitle = "A Biography", isbn13 = "9780156701600", year = "1928", pages = "333", + language = "English", copiesTotal = 2, + authors = listOf( + StaffAuthorRef(1, "Virginia Woolf", "principale"), + StaffAuthorRef(7, "Nadia Fusini", "traduttore"), + ), + publishers = listOf(IdName(5, "Hogarth")), + genre = IdName(3, "Romanzo"), + location = StaffLocation(shelfId = 1, levelId = 2, position = 4), + ) + + @Test fun formFromRecordKeepsAuthorsButNotContributors() { + val f = StaffBookForm.from(original) + assertEquals(listOf(PickedEntity(1, "Virginia Woolf")), f.authors) + assertEquals(1, f.shelfId) + assertEquals(2, f.levelId) + } + + @Test fun unchangedEditSendsAnEmptyPatch() { + assertEquals(JsonObject(emptyMap()), StaffBookWrite.update(original, StaffBookForm.from(original))) + } + + @Test fun editSendsOnlyTheChangedFields() { + val patch = StaffBookWrite.update(original, StaffBookForm.from(original).copy(title = "Orlando (new)", subtitle = "")) + assertEquals(setOf("title", "subtitle"), patch.keys) + assertEquals("", patch.str("subtitle")) // cleared text is sent as "" + } + + @Test fun clearedNumberIsSentAsNull() { + val patch = StaffBookWrite.update(original, StaffBookForm.from(original).copy(year = "")) + assertEquals(JsonNull, patch["year"]) + } + + @Test fun changedAuthorsResendPreservedContributors() { + val patch = StaffBookWrite.update( + original, + StaffBookForm.from(original).copy(authors = listOf(PickedEntity(1, "Virginia Woolf"), PickedEntity(0, "Second"))), + ) + assertEquals(listOf(1), patch.ints("author_ids")) + assertEquals(listOf("Second"), patch.strs("authors_new")) + assertEquals(listOf(7), patch["contributors"]!!.jsonObject.ints("translator_ids")) + assertFalse("publisher_ids" in patch) + } + + @Test fun removingLocationSendsNulls() { + val patch = StaffBookWrite.update(original, StaffBookForm.from(original).copy(shelfId = null, levelId = null)) + assertEquals(JsonNull, patch["shelf_id"]) + assertEquals(JsonNull, patch["level_id"]) + } + + @Test fun previewPrefillUsesTheScannedIdentifier() { + val f = StaffBookForm.fromPreview( + IsbnPreview(title = "Il nome della rosa", authors = listOf(JsonPrimitive("Umberto Eco")), publisher = "Bompiani", + year = "1980", pages = "503", coverUrl = "https://c/x.jpg", isbn13 = "0000000000000"), + Identifier.Isbn13("9788804668237", "8804668237"), + ) + assertEquals("9788804668237", f.isbn13) + assertEquals("8804668237", f.isbn10) + assertEquals(listOf(PickedEntity(0, "Umberto Eco")), f.authors) + assertEquals(listOf(PickedEntity(0, "Bompiani")), f.publishers) + assertEquals("1980", f.year) + assertTrue(f.canSave()) + } + + @Test fun nonBookEanGoesToTheEanField() { + val f = StaffBookForm.fromIdentifier(Identifier.Ean("4006381333931")) + assertEquals("4006381333931", f.ean) + assertEquals("", f.isbn13) + assertFalse(f.canSave()) // still needs a title + } + + @Test fun scraperJunkIsDroppedNotBlockingSave() { + val f = StaffBookForm.fromPreview(IsbnPreview(title = "X", year = "circa 1900", pages = "xii, 200", price = "EUR 12"), null) + assertEquals("", f.year) + assertEquals("", f.pages) + assertEquals("", f.price) + assertTrue(f.canSave()) + } + + private fun JsonObject.str(k: String) = this[k]!!.jsonPrimitive.content + private fun JsonObject.num(k: String) = this[k]!!.jsonPrimitive.int + private fun JsonObject.ints(k: String) = (this[k] as JsonArray).map { it.jsonPrimitive.int } + private fun JsonObject.strs(k: String) = this[k]!!.jsonArray.map { it.jsonPrimitive.content } + + @Test fun shelfWithoutLevelIsAValidationError() { + assertTrue(FormField.LEVEL in valid.copy(shelfId = 1, levelId = null).validate()) + assertTrue(FormField.LEVEL in valid.copy(shelfId = 1, levelId = null).validate(isEdit = true)) + assertTrue(valid.copy(shelfId = 1, levelId = 2).canSave()) + assertTrue(valid.copy(shelfId = null, levelId = null).canSave()) + } + + @Test fun changingShelfWithoutLevelNeverSendsNulls() { + // Blocked by validate(); even if the body were built, no partial or null location goes out. + val patch = StaffBookWrite.update(original, StaffBookForm.from(original).copy(shelfId = 9, levelId = null)) + assertFalse("shelf_id" in patch) + assertFalse("level_id" in patch) + } + + @Test fun storedLocationWithoutLevelIsLeftAlone() { + val noLevel = original.copy(location = StaffLocation(shelfId = 1, levelId = null, label = "A")) + val form = StaffBookForm.from(noLevel) + assertEquals(null, form.shelfId) + assertTrue(form.canSave(isEdit = true)) + assertEquals(JsonObject(emptyMap()), StaffBookWrite.update(noLevel, form)) + } + + @Test fun duplicateAddCopyAddsOneWhenEditingAndTheRequestedCopiesWhenCreating() { + val creating = com.pinakes.app.ui.screens.staff.StaffBookEditUiState(bookId = 0, form = valid.copy(copies = 3)) + assertEquals(3, creating.duplicateAddQuantity) + // Editing a record with 5 copies whose new ISBN collides: never add 5 copies to the other book. + val editing = com.pinakes.app.ui.screens.staff.StaffBookEditUiState(bookId = 10, form = valid.copy(copies = 5)) + assertEquals(1, editing.duplicateAddQuantity) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/StaffCirculationModelsParsingTest.kt b/app/src/test/java/com/pinakes/app/staff/StaffCirculationModelsParsingTest.kt new file mode 100644 index 0000000..a1f4f14 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/StaffCirculationModelsParsingTest.kt @@ -0,0 +1,147 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.CirculationSummary +import com.pinakes.app.data.model.CopyLookup +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.HealthPayload +import com.pinakes.app.data.model.ImportJob +import com.pinakes.app.data.model.LoanCreateRequest +import com.pinakes.app.data.model.StaffArchiveDetail +import com.pinakes.app.data.model.StaffLoan +import com.pinakes.app.data.model.StaffLookups +import com.pinakes.app.data.model.StaffSeries +import com.pinakes.app.data.model.StaffUserDetail +import com.pinakes.app.data.network.parseErrorBody +import com.pinakes.app.data.staff.LoanAction +import com.pinakes.app.data.staff.LoanActions +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 Mobile API 1.7.0 payloads decode tolerantly: defaults for absent keys, unknown values ignored. */ +class StaffCirculationModelsParsingTest { + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + + @Test fun healthStaffFlagsDefaultOffAndParseWhenSent() { + val old = json.decodeFromString("""{"features":{"staff_catalog":true}}""") + assertFalse(old.features.staffCirculation || old.features.staffCopies || old.features.staffArchives || old.features.staffLibraryThing) + val new = json.decodeFromString( + """{"features":{"staff_circulation":true,"staff_copies":true,"staff_series":true,"staff_merges":true,"staff_labels":true, + "staff_imports":true,"staff_covers":true,"staff_desiderata":true,"staff_archives":true,"staff_periodicals":true, + "staff_bookclub":true,"staff_frbr":true,"staff_digital_library":true,"staff_librarything":true}}""" + ) + with(new.features) { + assertTrue(staffCirculation && staffCopies && staffSeries && staffMerges && staffLabels && staffImports && staffCovers) + assertTrue(staffDesiderata && staffArchives && staffPeriodicals && staffBookClub && staffFrbr && staffDigitalLibrary && staffLibraryThing) + } + } + + @Test fun staffLoanFullShape() { + val loan = json.decodeFromString( + """{"id":7,"status":"in_ritardo","status_label":"In ritardo","origin":"diretto", + "book":{"id":5,"title":"Orlando","cover_url":"/c.jpg","isbn13":"9780156701600"}, + "copy":{"id":11,"inventory_number":"INV-0011","status":"prestato"}, + "user":{"id":3,"name":"Ada Rossi","email":"ada@example.org","card_number":"C-12","status":"attivo"}, + "processed_by":{"id":1,"name":"Admin"},"loaned_at":"2026-09-01","due_at":"2026-10-01", + "days_overdue":13,"renewals":1,"max_renewals":2,"penalty":"4.50","recall_count":2, + "actions":["return","edit","recall","receipt","teleport"]}""" + ) + assertEquals(7, loan.id) + assertEquals("INV-0011", loan.copy?.inventoryNumber) + assertEquals("C-12", loan.user.cardNumber) + assertEquals(4.5, loan.penalty!!, 0.0001) + assertEquals(13, loan.daysOverdue) + assertEquals(listOf(LoanAction.RETURN, LoanAction.EDIT, LoanAction.RECALL, LoanAction.RECEIPT), LoanActions.of(loan)) + } + + @Test fun staffLoanMinimalShapeUsesDefaults() { + val loan = json.decodeFromString("""{"id":1,"status":"pendente"}""") + assertNull(loan.copy) + assertEquals("", loan.book.title) + assertEquals(0, loan.renewals) + assertTrue(loan.actions.isEmpty()) + assertFalse(loan.returnedLate) + } + + @Test fun summaryAndUserDetail() { + val s = json.decodeFromString>( + """{"data":{"pending":2,"pickup_ready":1,"scheduled":0,"active":5,"overdue":3,"reservations":4,"due_today":1, + "returned_today":2,"today":"2026-10-14","settings":{"loan_duration_days":30,"max_renewals":2}}}""" + ).data!! + assertEquals(3, s.overdue) + assertEquals(30, s.settings?.loanDurationDays) + val u = json.decodeFromString( + """{"id":3,"name":"Ada","status":"sospeso","eligible":false,"eligibility_reason":"user_suspended", + "loans":[{"id":7,"status":"in_corso"}],"reservations":[{"id":2,"status":"attiva","queue_position":1}]}""" + ) + assertFalse(u.eligible) + assertEquals(1, u.loans.size) + assertEquals(1, u.reservations.first().queuePosition) + } + + @Test fun copyLookupFoundAndNotFound() { + val found = json.decodeFromString( + """{"found":true,"copy":{"id":11,"inventory_number":"INV-0011","status":"prestato","held":false,"deletable":false, + "book":{"id":5,"title":"Orlando"},"available":false,"loan":{"id":7,"status":"in_corso","user":{"id":3,"name":"Ada"}}}}""" + ) + assertTrue(found.found) + assertEquals(7, found.copy?.loan?.id) + assertEquals(false, found.copy?.available) + val missing = json.decodeFromString("""{"found":false,"copy":null}""") + assertFalse(missing.found) + assertNull(missing.copy) + } + + @Test fun createRequestAlwaysCarriesImmediate() { + val body = json.encodeToString(LoanCreateRequest.serializer(), LoanCreateRequest(userId = 3, bookId = 5, immediate = false)) + assertTrue(body, body.contains("\"immediate\":false")) + assertFalse(body, body.contains("copy_code")) + } + + @Test fun errorMetaCarriesFieldProcessedAndRetry() { + val f = parseErrorBody("""{"data":null,"meta":{"field":"due_at","processed":20,"retry_after":30},"error":{"code":"out_of_order","message":"x"}}""", 409) + assertEquals("out_of_order", f.code) + assertEquals("due_at", f.meta?.field) + assertEquals(20, f.meta?.processed) + assertEquals(30, f.meta?.retryAfter) + } + + @Test fun lookupsEditableCopyStates() { + val withFlag = json.decodeFromString( + """{"copy_states":[{"value":"disponibile","label":"Disponibile","editable":true},{"value":"prestato","label":"In prestito","editable":false}, + {"value":"perso","label":"Perso","editable":true}],"return_outcomes":[{"value":"restituito","label":"Restituito"}]}""" + ) + assertEquals(listOf("disponibile", "perso"), withFlag.editableCopyStateOptions.map { it.value }) + assertEquals("restituito", withFlag.returnOutcomeOptions.single().value) + val older = json.decodeFromString("""{"copy_states":["disponibile","prestato","prenotato","manutenzione"]}""") + assertEquals(listOf("disponibile", "manutenzione"), older.editableCopyStateOptions.map { it.value }) + } + + @Test fun contentAndPluginShapes() { + val series = json.decodeFromString( + """{"id":4,"name":"Discworld","type":"serie","book_count":41,"min_number":1,"max_number":"41","complete":true, + "children":[{"id":5,"name":"Rincewind","parent_id":4}]}""" + ) + assertEquals("1", series.minNumber) + assertEquals(5, series.children.single().id) + val job = json.decodeFromString( + """{"id":"6f1c","kind":"csv","file_name":"books.csv","total_rows":30,"processed":10,"errors":2, + "error_details":["Row 3: missing title",{"message":"Row 9: bad ISBN"}]}""" + ) + assertEquals(listOf("Row 3: missing title", "Row 9: bad ISBN"), job.errorLines) + assertFalse(job.complete) + val archive = json.decodeFromString( + """{"id":3,"title":"Fondo X","published":false,"fields":{"reference_code":"IT-1","date_start":1920,"extent":null}, + "files":[{"id":2,"url":"/a.pdf","mime":"application/pdf"}],"authorities":[{"id":9,"name":"Rossi","role":"creator"}]}""" + ) + assertEquals("IT-1", archive.field("reference_code")) + assertEquals("1920", archive.field("date_start")) + assertEquals("", archive.field("extent")) + assertEquals("", archive.field("missing")) + assertFalse(archive.published) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/StaffModelsParsingTest.kt b/app/src/test/java/com/pinakes/app/staff/StaffModelsParsingTest.kt new file mode 100644 index 0000000..6479eec --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/StaffModelsParsingTest.kt @@ -0,0 +1,139 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.CalendarStatus +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.HealthPayload +import com.pinakes.app.data.model.IsbnLookup +import com.pinakes.app.data.model.StaffBook +import com.pinakes.app.data.model.StaffGenreNode +import com.pinakes.app.data.model.StaffLookups +import com.pinakes.app.data.model.SyncPage +import com.pinakes.app.data.model.flatten +import com.pinakes.app.data.network.parseErrorBody +import com.pinakes.app.data.repository.isForbidden +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 Mobile API 1.6.0 payloads decode with the app's tolerant Json settings. */ +class StaffModelsParsingTest { + + // Same configuration as NetworkModule.json. + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + + @Test fun healthFeaturesDefaultOffOnOlderServers() { + val old = json.decodeFromString("""{"name":"Lib","features":{"catalog":true}}""") + assertFalse(old.features.staffCatalog) + assertFalse(old.features.catalogSync) + assertFalse(old.features.calendarFeed) + val new = json.decodeFromString("""{"features":{"staff_catalog":true,"catalog_sync":true,"calendar_feed":true}}""") + assertTrue(new.features.staffCatalog && new.features.catalogSync && new.features.calendarFeed) + } + + @Test fun lookupsAcceptObjectsAndStrings() { + val l = json.decodeFromString( + """{"languages":[{"code":"it","label":"Italiano"}],"media_types":[{"value":"libro","label":"Libro"}], + "formats":["Brossura","Copertina rigida"],"acquisition_types":[{"value":"dono"}],"unknown":1}""" + ) + assertEquals("it", l.languageOptions.single().value) + assertEquals("Italiano", l.languageOptions.single().label) + assertEquals("Libro", l.mediaTypeOptions.single().label) + assertEquals(listOf("Brossura", "Copertina rigida"), l.formatOptions.map { it.label }) + assertEquals("dono", l.acquisitionTypeOptions.single().label) + } + + @Test fun isbnLookupWithPreviewAndExisting() { + val r = json.decodeFromString( + """{"identifier":{"input":"9788804668237","isbn10":"8804668237","isbn13":"9788804668237","ean":null,"kind":"isbn"}, + "found":true,"existing":{"id":12,"title":"Il nome della rosa","copies_total":3,"location":"A.1.4"}, + "preview":{"title":"Il nome della rosa","authors":["Umberto Eco",{"name":"Altro"}],"year":1980,"pages":"503", + "price":12.5,"cover_url":"https://c/x.jpg"},"sources_checked":["google",{"name":"sbn"}]}""" + ) + assertTrue(r.found) + assertEquals(12, r.existing?.id) + assertEquals("A.1.4", r.existing?.locationLabel) + assertEquals(listOf("Umberto Eco", "Altro"), r.preview?.authorNames) + assertEquals("1980", r.preview?.year) // number accepted into a string field + assertEquals("12.5", r.preview?.price) + } + + @Test fun isbnLookupNotFound() { + val r = json.decodeFromString("""{"identifier":{"input":"4006381333931","kind":"ean"},"found":false,"existing":null,"preview":null}""") + assertFalse(r.found) + assertNull(r.preview) + assertEquals("ean", r.identifier.kind) + } + + @Test fun staffBookDecodesNestedObjects() { + val b = json.decodeFromString( + """{"id":5,"title":"T","year":2001,"pages":120,"genre":{"id":3,"name":"Romanzo"},"subgenre":null, + "authors":[{"id":1,"name":"A","role":"principale"},{"id":2,"name":"B","role":"traduttore"}], + "publishers":[{"id":9,"name":"P"}],"location":{"shelf_id":1,"shelf_code":"A","level_id":2,"level_number":1,"position":3,"label":"A.1.3"}, + "copies":[{"id":1,"inventory_number":"LIB-5-C1","status":"disponibile"}],"is_wanted":false,"cover_is_local":true}""" + ) + assertEquals("2001", b.year) + assertEquals("120", b.pages) + assertEquals(2, b.authors.size) + assertEquals("A.1.3", b.location?.label) + assertEquals("LIB-5-C1", b.copies.single().inventoryNumber) + } + + @Test fun genreTreeFlattensWithPaths() { + val tree = json.decodeFromString>( + """[{"id":1,"name":"Narrativa","parent_id":null,"children":[{"id":2,"name":"Giallo","parent_id":1,"children":[]}]}]""" + ) + val flat = tree.flatten() + assertEquals(listOf(1, 2), flat.map { it.id }) + assertEquals("Narrativa › Giallo", flat[1].path) + assertEquals(1, flat[1].parentId) + } + + @Test fun syncPageDecodes() { + val env = json.decodeFromString>( + """{"data":{"items":[{"id":1,"change":"upsert","changed_at":"2026-10-13 10:00:00","book":{"id":1,"title":"T","pages":10}}, + {"id":2,"change":"delete","changed_at":"2026-10-13 10:00:01"}],"server_time":"2026-10-13 10:05:00","full_resync":false}, + "meta":{"next_cursor":"abc","has_more":true,"count":2},"error":null}""" + ) + assertEquals(2, env.data?.items?.size) + assertEquals("2026-10-13 10:05:00", env.data?.serverTime) + assertEquals(true, env.meta?.hasMore) + assertEquals("abc", env.meta?.nextCursor) + } + + @Test fun calendarStatusDecodes() { + val s = json.decodeFromString("""{"enabled":true,"feed_url":"https://h/f.ics","webcal_url":"webcal://h/f.ics","created_at":"2026-10-01 09:00:00","rotated_at":null}""") + assertTrue(s.enabled) + assertEquals("webcal://h/f.ics", s.webcalUrl) + assertFalse(json.decodeFromString("""{"enabled":false}""").enabled) + } + + @Test fun duplicateErrorKeepsTheExistingRecordInMeta() { + val f = parseErrorBody( + """{"data":null,"meta":{"existing":{"id":12,"title":"Il nome della rosa"}},"error":{"code":"duplicate","message":"Già presente"}}""", + 409, + ) + assertEquals("duplicate", f.code) + assertEquals(409, f.httpStatus) + assertTrue(f.meta?.existing != null) + } + + @Test fun conflictReasonIsKept() { + val f = parseErrorBody("""{"meta":{"reason":"active_circulation"},"error":{"code":"conflict","message":"x"}}""", 409) + assertEquals("active_circulation", f.meta?.reason) + } + + @Test fun onlyTheApiForbiddenCodeRevokesTheRole() { + val api = parseErrorBody("""{"data":null,"error":{"code":"forbidden","message":"Staff only"}}""", 403) + assertTrue(api.isForbidden()) + // A WAF/proxy page: the status maps to "forbidden" but did not come from the API. + val waf = parseErrorBody("Request blocked", 403) + assertEquals("forbidden", waf.code) + assertFalse(waf.fromEnvelope) + assertFalse(waf.isForbidden()) + assertFalse(parseErrorBody("", 403).isForbidden()) + assertFalse(parseErrorBody("""{"error":{"code":"app_access_disabled","message":"x"}}""", 403).isForbidden()) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/StaffStringsPluralTest.kt b/app/src/test/java/com/pinakes/app/staff/StaffStringsPluralTest.kt new file mode 100644 index 0000000..ca00f07 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/StaffStringsPluralTest.kt @@ -0,0 +1,33 @@ +package com.pinakes.app.staff + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * Staff texts never put a counted noun right after a number ("1 books", "1 Bücher"): the i18n + * pipeline generates plain strings, so counts are written as "Books: 1" in every language. + */ +class StaffStringsPluralTest { + + private val nouns = listOf( + "books", "loans", "days", "rows", "units", "records", "copies", "reservations", + "libri", "prestiti", "giorni", "righe", "unità", "schede", "copie", + "livres", "prêts", "jours", "lignes", "unités", "notices", "exemplaires", + "Bücher", "Ausleihen", "Tage", "Zeilen", "Einheiten", "Einträge", "Exemplare", + ) + private val pattern = Regex("%\\d\\${'$'}d\\s+(" + nouns.joinToString("|") + ")\\b") + + private fun i18nDir(): File = listOf(File("../i18n"), File("i18n")).first { it.isDirectory } + + @Test fun noCountedNounAfterANumberInStaffStrings() { + val offenders = mutableListOf() + for (lang in listOf("en", "it", "fr", "de")) { + val text = File(i18nDir(), "$lang.json").readText() + Regex(""""(staff_[a-z0-9_]+)":\s*"((?:[^"\\]|\\.)*)"""").findAll(text).forEach { m -> + if (pattern.containsMatchIn(m.groupValues[2])) offenders += "$lang ${m.groupValues[1]}: ${m.groupValues[2]}" + } + } + assertTrue(offenders.joinToString("\n"), offenders.isEmpty()) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/SubmissionKeysTest.kt b/app/src/test/java/com/pinakes/app/staff/SubmissionKeysTest.kt new file mode 100644 index 0000000..a826a49 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/SubmissionKeysTest.kt @@ -0,0 +1,47 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.LoanCreateRequest +import com.pinakes.app.data.staff.StaffErrors +import com.pinakes.app.data.staff.SubmissionKeys +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +/** One Idempotency-Key per submission: stable across retries, new for a new payload or after a reset. */ +class SubmissionKeysTest { + + private var counter = 0 + private val keys = SubmissionKeys { "k${++counter}" } + private val loan = LoanCreateRequest(userId = 3, bookId = 5, immediate = true) + + @Test fun retriesOfTheSameSubmissionReuseTheKey() { + val first = keys.keyFor(loan) + assertEquals(first, keys.keyFor(loan.copy())) + assertEquals(first, keys.keyFor(LoanCreateRequest(userId = 3, bookId = 5, immediate = true))) + } + + @Test fun aChangedPayloadIsANewSubmission() { + val first = keys.keyFor(loan) + val second = keys.keyFor(loan.copy(dueAt = "2026-11-30")) + assertNotEquals(first, second) + // Going back to the first payload is again a new submission (the server would see a new body). + assertNotEquals(first, keys.keyFor(loan)) + } + + @Test fun resetAfterSuccessOrConflictGivesAFreshKey() { + val first = keys.keyFor(loan) + keys.reset() + assertNotEquals(first, keys.keyFor(loan)) + } + + @Test fun defaultKeysAreUuids() { + val key = SubmissionKeys().keyFor(loan) + assertEquals(36, key.length) + assertNotNull(java.util.UUID.fromString(key)) + } + + @Test fun conflictHasItsOwnMessage() { + assertNotNull(StaffErrors.messageRes(SubmissionKeys.CONFLICT)) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/SyncMergeTest.kt b/app/src/test/java/com/pinakes/app/staff/SyncMergeTest.kt new file mode 100644 index 0000000..400b748 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/SyncMergeTest.kt @@ -0,0 +1,105 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.SyncBook +import com.pinakes.app.data.model.SyncItem +import com.pinakes.app.data.model.SyncPage +import com.pinakes.app.data.sync.SyncContinuation +import com.pinakes.app.data.sync.SyncMerge +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SyncMergeTest { + + private fun up(id: Int, title: String = "T$id") = SyncItem(id = id, change = "upsert", book = SyncBook(id = id, title = title)) + private fun del(id: Int) = SyncItem(id = id, change = "delete") + private fun page(vararg items: SyncItem, time: String? = "2026-10-13 10:00:00", full: Boolean = false) = + SyncPage(items = items.toList(), serverTime = time, fullResync = full) + + private val day = 24L * 60 * 60 * 1000 + + @Test fun deltaCollectsUpsertsAndDeletes() { + val r = SyncMerge.merge(listOf(page(up(1), del(2), up(3))), requestedFull = false) + assertFalse(r.fullResync) + assertEquals(listOf(1, 3), r.upserts.map { it.id }) + assertEquals(listOf(2), r.deleteIds) + assertEquals("2026-10-13 10:00:00", r.serverTime) + } + + @Test fun lastChangeForAnIdWinsAcrossPages() { + val r = SyncMerge.merge( + listOf(page(up(1, "old"), up(2)), page(del(1), up(2, "new"), up(4)), page(up(4, "back"))), + requestedFull = false, + ) + assertEquals(listOf(1), r.deleteIds) + assertEquals(mapOf(2 to "new", 4 to "back"), r.upserts.associate { it.id to it.title }) + } + + @Test fun deleteThenUpsertIsAnUpsert() { + val r = SyncMerge.merge(listOf(page(del(5)), page(up(5))), requestedFull = false) + assertTrue(r.deleteIds.isEmpty()) + assertEquals(listOf(5), r.upserts.map { it.id }) + } + + @Test fun serverDemandedFullResyncDropsDeletes() { + val r = SyncMerge.merge(listOf(page(up(1), del(2), full = true)), requestedFull = false) + assertTrue(r.fullResync) + assertTrue(r.deleteIds.isEmpty()) + assertEquals(listOf(1), r.upserts.map { it.id }) + } + + @Test fun requestedFullIsFullEvenIfPagesDoNotSaySo() { + assertTrue(SyncMerge.merge(listOf(page(up(1))), requestedFull = true).fullResync) + } + + @Test fun upsertWithoutBookIsIgnoredAndInvalidIdsSkipped() { + val r = SyncMerge.merge(listOf(page(SyncItem(id = 3, change = "upsert", book = null), up(0), del(-1))), requestedFull = false) + assertTrue(r.upserts.isEmpty()) + assertTrue(r.deleteIds.isEmpty()) + } + + @Test fun bookIdFollowsTheItemId() { + val r = SyncMerge.merge(listOf(page(SyncItem(id = 8, book = SyncBook(id = 0, title = "x")))), requestedFull = false) + assertEquals(8, r.upserts.single().id) + } + + @Test fun watermarkComesFromThePagesAndIsNullWithoutOne() { + assertEquals("2026-10-13 10:00:00", SyncMerge.merge(listOf(page(time = null), page(time = "2026-10-13 10:00:00")), false).serverTime) + assertNull(SyncMerge.merge(listOf(page(time = null)), false).serverTime) + assertNull(SyncMerge.merge(emptyList(), false).serverTime) + } + + @Test fun forcedFullWithoutWatermarkOrHistory() { + assertTrue(SyncMerge.shouldForceFull(since = null, lastFullAtMs = 0, nowMs = 1)) + assertTrue(SyncMerge.shouldForceFull(since = "2026-10-13 10:00:00", lastFullAtMs = null, nowMs = 1)) + } + + @Test fun weeklyFullResync() { + val now = 100 * day + assertFalse(SyncMerge.shouldForceFull("t", lastFullAtMs = now - 6 * day, nowMs = now)) + assertTrue(SyncMerge.shouldForceFull("t", lastFullAtMs = now - 7 * day, nowMs = now)) + assertTrue(SyncMerge.shouldForceFull("t", lastFullAtMs = now - 30 * day, nowMs = now)) + } + + @Test fun clockMovedBackwardsForcesFull() { + assertTrue(SyncMerge.shouldForceFull("t", lastFullAtMs = 10 * day, nowMs = 9 * day)) + } + + @Test fun continuationFollowsTheCursor() { + assertEquals(SyncContinuation.Next("abc"), SyncMerge.continuation(hasMore = true, nextCursor = "abc")) + assertEquals(SyncContinuation.Done, SyncMerge.continuation(hasMore = false, nextCursor = "abc")) + assertEquals(SyncContinuation.Done, SyncMerge.continuation(hasMore = false, nextCursor = null)) + } + + @Test fun hasMoreWithoutCursorIsBroken() { + assertEquals(SyncContinuation.Broken, SyncMerge.continuation(hasMore = true, nextCursor = null)) + assertEquals(SyncContinuation.Broken, SyncMerge.continuation(hasMore = true, nextCursor = " ")) + } + + @Test fun serversWithoutHasMorePageByCursor() { + assertEquals(SyncContinuation.Next("c"), SyncMerge.continuation(hasMore = null, nextCursor = "c")) + assertEquals(SyncContinuation.Done, SyncMerge.continuation(hasMore = null, nextCursor = "")) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/Tier2FormsTest.kt b/app/src/test/java/com/pinakes/app/staff/Tier2FormsTest.kt new file mode 100644 index 0000000..6f5e4e5 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/Tier2FormsTest.kt @@ -0,0 +1,132 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.ClubState +import com.pinakes.app.data.model.PeriodicalScan +import com.pinakes.app.data.model.StaffClub +import com.pinakes.app.data.model.StaffWork +import com.pinakes.app.data.staff.ClubForm +import com.pinakes.app.data.staff.ExpressionForm +import com.pinakes.app.data.staff.IssueStatus +import com.pinakes.app.data.staff.StaffAreas +import com.pinakes.app.data.staff.SubscriptionForm +import com.pinakes.app.data.staff.WorkForm +import com.pinakes.app.data.staff.WorkflowDraft +import com.pinakes.app.data.store.InstanceFeatures +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Tier 2 bridges: tolerant parsing of the real shapes, and the form bodies sent back. */ +class Tier2FormsTest { + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + + @Test fun scanLookupShapes() { + val issue = json.decodeFromString( + """{"found":true,"match":"issue","action":"receive","issue":{"id":4,"numero":"12","stato":"atteso","annata_id":2,"anno":2026,"testata_id":16,"titolo":"Internazionale","url":"x"},"message":"m"}""" + ) + assertEquals("12", issue.issue?.number) + assertEquals(16, issue.issue?.periodicalId) + val title = json.decodeFromString("""{"found":true,"match":"title","action":null,"title":{"id":16,"titolo":"Internazionale"}}""") + assertEquals("Internazionale", title.title?.title) + val none = json.decodeFromString("""{"found":false,"match":"none","action":null,"message":"Nessuna corrispondenza"}""") + assertFalse(none.found) + } + + @Test fun issueStatusRules() { + assertTrue(IssueStatus.receivable("atteso") && IssueStatus.receivable("reclamato")) + assertFalse(IssueStatus.receivable("posseduto")) + assertTrue(IssueStatus.claimable("mancante")) + assertFalse(IssueStatus.claimable("posseduto") || IssueStatus.claimable("scartato")) + } + + @Test fun subscriptionBody() { + val form = SubscriptionForm(periodicalId = 16, supplier = " Edicola ", cost = "12,50", startDate = "2026-01-01", endDate = "2026-12-31") + assertTrue(form.valid) + val body = form.toJson() + assertEquals(16, body["periodical_id"]!!.jsonPrimitive.int) + assertEquals("Edicola", body["supplier"]!!.jsonPrimitive.content) + assertEquals(12.5, body["cost"]!!.jsonPrimitive.content.toDouble(), 0.0) + assertFalse(SubscriptionForm(id = 3, periodicalId = 16, supplier = "x").toJson().containsKey("periodical_id")) + assertFalse(form.copy(endDate = "2025-01-01").valid) + assertFalse(form.copy(cost = "abc").valid) + } + + @Test fun clubWorkflowParsesEmptyFlagsAndRoundTrips() { + val club = json.decodeFromString( + """{"id":1,"name":"C","privacy":"public","workflow":[{"key":"proposed","label":"Proposto","color":"#6b7280","flags":{"votable":true}}, + {"key":"voting","label":"In votazione","color":"#8b5cf6","flags":{"voting":true}},{"key":"selected","label":"Scelto","color":"#0ea5e9","flags":[]}], + "members":[{"id":5,"user_id":3,"name":"Ada","email":"a@x","role":"member","role_name":"Membro","status":"active"}]}""" + ) + assertTrue(club.workflow[1].flag("voting")) + assertFalse(club.workflow[2].flag("voting")) + val draft = WorkflowDraft.from(club.workflow) + val moved = draft.move(2, -1) + assertEquals(listOf("proposed", "selected", "voting"), moved.states.map { it.key }) + assertEquals(draft, draft.move(0, -1)) + assertEquals(2, draft.remove(0).states.size) + assertEquals(2, WorkflowDraft(draft.states.take(2)).remove(0).states.size) + val states = draft.add("Nuovo").toJson()["states"]!!.jsonArray + assertEquals(4, states.size) + assertEquals("", states[3].jsonObject["key"]!!.jsonPrimitive.content) + assertTrue(states[1].jsonObject["flags"]!!.jsonObject["voting"]!!.jsonPrimitive.boolean) + assertFalse(WorkflowDraft(draft.states.take(1)).valid) + } + + @Test fun savingAWorkflowKeepsTheFlagsTheEditorDoesNotModel() { + val club = json.decodeFromString( + """{"id":1,"name":"C","privacy":"public","workflow":[{"key":"proposed","label":"Proposto","color":"#6b7280","flags":{"votable":true}}, + {"key":"voting","label":"In votazione","color":"#8b5cf6","flags":{"voting":true,"future_flag":"x"}}],"members":[]}""" + ) + // Only a label changes: `votable` and a flag from a later plugin version must survive. + val states = WorkflowDraft.from(club.workflow).update(0) { it.copy(label = "Proposte") }.toJson()["states"]!!.jsonArray + val proposed = states[0].jsonObject["flags"]!!.jsonObject + assertTrue(proposed["votable"]!!.jsonPrimitive.boolean) + assertFalse(proposed["voting"]!!.jsonPrimitive.boolean) + val voting = states[1].jsonObject["flags"]!!.jsonObject + assertEquals("x", voting["future_flag"]!!.jsonPrimitive.content) + assertTrue(voting["voting"]!!.jsonPrimitive.boolean) + // The editor's own flags still win over the stored ones. + val cleared = WorkflowDraft.from(club.workflow).update(1) { it.copy(voting = false) }.toJson()["states"]!!.jsonArray + assertFalse(cleared[1].jsonObject["flags"]!!.jsonObject["voting"]!!.jsonPrimitive.boolean) + } + + @Test fun clubFormValidation() { + assertTrue(ClubForm(name = "Lettori").valid) + assertFalse(ClubForm(name = "Lettori", color = "red").valid) + assertFalse(ClubForm(name = "Lettori", maxMembers = "0").valid) + assertEquals(JsonNull, ClubForm(name = "L").toJson()["max_members"]) + assertEquals(12, ClubForm(name = "L", maxMembers = "12").toJson()["max_members"]!!.jsonPrimitive.int) + } + + @Test fun workAndExpressionBodies() { + val w = json.decodeFromString("""{"id":2,"uniform_title":"Odissea","main_author_id":7,"created_from":-725,"expressions":[{"id":3,"work_id":2,"type":"traduzione","language":"ita","year":1963}]}""") + val form = WorkForm.from(w) + assertEquals("-725", form.createdFrom) + assertTrue(form.valid) + val body = form.copy(createdTo = "").toJson() + assertEquals(-725, body["created_from"]!!.jsonPrimitive.int) + assertEquals(JsonNull, body["created_to"]) + assertFalse(WorkForm().valid) + val e = ExpressionForm.from(w.expressions.single()) + assertEquals("traduzione", e.type) + assertEquals(1963, e.toJson()["year"]!!.jsonPrimitive.int) + assertFalse(e.copy(type = "film").valid) + } + + @Test fun tier2AreasNeedTheirFlags() { + val f = InstanceFeatures(staffCatalogAvailable = true, staffPeriodicalsAvailable = true, staffBookClubAvailable = true, staffFrbrAvailable = true) + val a = StaffAreas.of("staff", f, online = true) + assertTrue(a.periodicals && a.bookClub && a.works && a.anyPlugin) + val none = StaffAreas.of("staff", InstanceFeatures(staffCatalogAvailable = true), online = true) + assertFalse(none.periodicals || none.bookClub || none.works || none.anyPlugin) + } +} diff --git a/app/src/test/resources/barcodes/periodical-issue-addon.y b/app/src/test/resources/barcodes/periodical-issue-addon.y new file mode 100644 index 0000000..a89370f Binary files /dev/null and b/app/src/test/resources/barcodes/periodical-issue-addon.y differ diff --git a/docs/UWE-PARITY.md b/docs/UWE-PARITY.md index b3cfdb6..2f4127d 100644 --- a/docs/UWE-PARITY.md +++ b/docs/UWE-PARITY.md @@ -26,6 +26,8 @@ I filtri di disponibilità si applicano ai libri con copie. La ricerca per gener - Android: 185 unit test, 19 test Compose su Android 15, zero errori Lint; build debug e release R8 riuscite. Test senza donazioni o modifiche al catalogo Bibliodoc di produzione. - Backend richiesto: Mobile API 1.5.0 e plugin aggiornati (Desiderata 1.2.0, Emeroteca 1.13.0, Archives 1.5.1). Le nuove sezioni dipendono dalle capability del server. Un server precedente continua a funzionare, ma non offre la nuova API delle raccolte; per i filtri analitici non supportati l’app richiede l’aggiornamento. - La gestione amministrativa e gli upload degli articoli/archivi aprono il PHP protetto nel browser. Non sono nuovi editor CRUD nativi. +- Aggiornamento 1.7.0 (Mobile API 1.6.0): per libri, autori, editori, generi e scaffali la gestione staff è ora nativa (lettore ISBN con modalità in serie, scheda manuale per materiale senza ISBN, copie, copertina). Articoli, archivi, utenti, prestiti e impostazioni restano sulle pagine PHP protette. Il catalogo è consultabile offline e i prestiti si possono sottoscrivere come calendario personale. +- Aggiornamento 1.8.0 (Mobile API 1.7.0): diventano native anche la circolazione (banco prestiti con contatori, richieste, ritiri, prestiti attivi e scaduti, storico, restituzione con esito e sanzione anche da codice copia scansionato, rinnovo, proroga e solleciti multipli, ricevuta PDF, nuovi prestiti e prenotazioni con calendario per utente), la gestione per singola copia con etichette, le collane, l’unione dei duplicati, l’importazione CSV e LibraryThing, copertine e arricchimento, le proposte Desiderata e le schede d’archivio con autorità e allegati. La circolazione sparisce senza connessione. Anche il banco emeroteca (ricezione e reclamo dei fascicoli da codice a barre, annate, fascicoli, abbonamenti), la gestione dei gruppi di lettura (impostazioni, percorso di lettura, membri) e le opere FRBR (espressioni, libri collegati) sono native. Restano sul web: utenti, impostazioni generali e gli articoli dell’emeroteca. - [#52](https://github.com/fabiodalez-dev/Pinakes/issues/52), import danese FBI/DBC, resta una richiesta separata non implementata: token e documentazione completa richiedono accesso DBC, che non è disponibile in questa sessione. Non è una funzionalità già presente nel server da sincronizzare. - [#57](https://github.com/fabiodalez-dev/Pinakes/issues/57) descrive l’installazione dell’estensione YAZ nel container QNAP; Uwe precisa che non è un difetto Pinakes. Non viene dichiarata risolta modificando il client Android. - Nessuna merge, pubblicazione sugli store o distribuzione in produzione effettuata. Il caso ANR sul vecchio emulatore GUI resta distinto dai test riusciti sull’AVD headless; non si dichiara risolto un ANR su dispositivi reali. diff --git a/docs/screenshots/staff/author-detail.png b/docs/screenshots/staff/author-detail.png new file mode 100644 index 0000000..fc4e4f5 Binary files /dev/null and b/docs/screenshots/staff/author-detail.png differ diff --git a/docs/screenshots/staff/book-copies.png b/docs/screenshots/staff/book-copies.png new file mode 100644 index 0000000..0c3ba75 Binary files /dev/null and b/docs/screenshots/staff/book-copies.png differ diff --git a/docs/screenshots/staff/book-edit.png b/docs/screenshots/staff/book-edit.png new file mode 100644 index 0000000..88b5129 Binary files /dev/null and b/docs/screenshots/staff/book-edit.png differ diff --git a/docs/screenshots/staff/circulation-desk.png b/docs/screenshots/staff/circulation-desk.png new file mode 100644 index 0000000..e752651 Binary files /dev/null and b/docs/screenshots/staff/circulation-desk.png differ diff --git a/docs/screenshots/staff/copy-scanner.png b/docs/screenshots/staff/copy-scanner.png new file mode 100644 index 0000000..e336f09 Binary files /dev/null and b/docs/screenshots/staff/copy-scanner.png differ diff --git a/docs/screenshots/staff/isbn-preview.png b/docs/screenshots/staff/isbn-preview.png new file mode 100644 index 0000000..80a6c14 Binary files /dev/null and b/docs/screenshots/staff/isbn-preview.png differ diff --git a/docs/screenshots/staff/loan-detail-overdue.png b/docs/screenshots/staff/loan-detail-overdue.png new file mode 100644 index 0000000..05d6249 Binary files /dev/null and b/docs/screenshots/staff/loan-detail-overdue.png differ diff --git a/docs/screenshots/staff/loan-detail-request.png b/docs/screenshots/staff/loan-detail-request.png new file mode 100644 index 0000000..0e8f877 Binary files /dev/null and b/docs/screenshots/staff/loan-detail-request.png differ diff --git a/docs/screenshots/staff/loans-overdue.png b/docs/screenshots/staff/loans-overdue.png new file mode 100644 index 0000000..8971ead Binary files /dev/null and b/docs/screenshots/staff/loans-overdue.png differ diff --git a/docs/screenshots/staff/new-loan.png b/docs/screenshots/staff/new-loan.png new file mode 100644 index 0000000..429d481 Binary files /dev/null and b/docs/screenshots/staff/new-loan.png differ diff --git a/docs/screenshots/staff/reservations.png b/docs/screenshots/staff/reservations.png new file mode 100644 index 0000000..97a6d4c Binary files /dev/null and b/docs/screenshots/staff/reservations.png differ diff --git a/docs/screenshots/staff/series-detail.png b/docs/screenshots/staff/series-detail.png new file mode 100644 index 0000000..a46ae35 Binary files /dev/null and b/docs/screenshots/staff/series-detail.png differ diff --git a/docs/screenshots/staff/staff-archives.png b/docs/screenshots/staff/staff-archives.png new file mode 100644 index 0000000..50b5e6e Binary files /dev/null and b/docs/screenshots/staff/staff-archives.png differ diff --git a/docs/screenshots/staff/staff-authors.png b/docs/screenshots/staff/staff-authors.png new file mode 100644 index 0000000..d2779e9 Binary files /dev/null and b/docs/screenshots/staff/staff-authors.png differ diff --git a/docs/screenshots/staff/staff-books.png b/docs/screenshots/staff/staff-books.png new file mode 100644 index 0000000..feb17bf Binary files /dev/null and b/docs/screenshots/staff/staff-books.png differ diff --git a/docs/screenshots/staff/staff-covers.png b/docs/screenshots/staff/staff-covers.png new file mode 100644 index 0000000..a03aaf5 Binary files /dev/null and b/docs/screenshots/staff/staff-covers.png differ diff --git a/docs/screenshots/staff/staff-desiderata.png b/docs/screenshots/staff/staff-desiderata.png new file mode 100644 index 0000000..02d0691 Binary files /dev/null and b/docs/screenshots/staff/staff-desiderata.png differ diff --git a/docs/screenshots/staff/staff-genres.png b/docs/screenshots/staff/staff-genres.png new file mode 100644 index 0000000..3274d6f Binary files /dev/null and b/docs/screenshots/staff/staff-genres.png differ diff --git a/docs/screenshots/staff/staff-hub-2.png b/docs/screenshots/staff/staff-hub-2.png new file mode 100644 index 0000000..ddf0ab5 Binary files /dev/null and b/docs/screenshots/staff/staff-hub-2.png differ diff --git a/docs/screenshots/staff/staff-hub.png b/docs/screenshots/staff/staff-hub.png new file mode 100644 index 0000000..526fe4c Binary files /dev/null and b/docs/screenshots/staff/staff-hub.png differ diff --git a/docs/screenshots/staff/staff-imports.png b/docs/screenshots/staff/staff-imports.png new file mode 100644 index 0000000..5bfce4e Binary files /dev/null and b/docs/screenshots/staff/staff-imports.png differ diff --git a/docs/screenshots/staff/staff-merges.png b/docs/screenshots/staff/staff-merges.png new file mode 100644 index 0000000..77f7389 Binary files /dev/null and b/docs/screenshots/staff/staff-merges.png differ diff --git a/docs/screenshots/staff/staff-periodicals.png b/docs/screenshots/staff/staff-periodicals.png new file mode 100644 index 0000000..4811dfd Binary files /dev/null and b/docs/screenshots/staff/staff-periodicals.png differ diff --git a/docs/screenshots/staff/staff-series.png b/docs/screenshots/staff/staff-series.png new file mode 100644 index 0000000..e9af9a9 Binary files /dev/null and b/docs/screenshots/staff/staff-series.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5858ad5..880c8f8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,8 @@ appcompat = "1.7.0" media3 = "1.4.1" room = "2.6.1" work = "2.9.1" +camerax = "1.4.1" +zxing = "3.5.3" sentry = "4.14.1" hilt = "2.52" hiltNavigationCompose = "1.2.0" @@ -25,8 +27,13 @@ coroutinesTest = "1.8.1" robolectric = "4.13" testCore = "1.6.1" testRunner = "1.6.2" +uiautomator = "2.3.0" [libraries] +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } +androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } +zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } @@ -72,6 +79,7 @@ androidx-test-core = { group = "androidx.test", name = "core", version.ref = "te androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "testRunner" } +androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/i18n/de.json b/i18n/de.json index 1ccbd0c..da7c81b 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -355,6 +355,7 @@ "reviews_empty": "Noch keine Rezensionen. Sei der Erste!", "reviews_rating_content_desc": "Bewertung: %1$s von 5", "reviews_write": "Rezension schreiben", + "reviews_borrow_first": "Du kannst dieses Buch bewerten, sobald du es ausgeliehen hast.", "reviews_others_title": "Was andere Leser sagen", "reviews_your_review": "Deine Rezension", "reviews_edit": "Bearbeiten", @@ -627,5 +628,819 @@ "credit_illustrator": "Illustrator", "credit_editor": "Herausgeber", "credit_colorist": "Kolorist", - "credit_contributor": "Mitwirkender" + "credit_contributor": "Mitwirkender", + "offline_results": "Sie sind offline: Diese Ergebnisse stammen aus der auf dem Gerät gespeicherten Kopie.", + "offline_lists_cached": "Sie sind offline: Dies ist die zuletzt auf dem Gerät gespeicherte Liste. Aktionen benötigen eine Verbindung.", + "offline_actions_unavailable": "Offline-Kopie. Ausleihen, Vormerkungen, Merkliste und Rezensionen benötigen eine Verbindung.", + "offline_availability_last_known": "Letzter Stand vom %1$s, Exemplare im Regal: %2$d von %3$d. Vor dem Besuch online prüfen.", + "offline_availability_unknown": "Die Verfügbarkeit kann offline nicht geprüft werden.", + "profile_action_staff": "Adminbereich", + "profile_action_calendar": "Ausleihen im Kalender", + "calendar_subscribe_title": "Ausleihen im Kalender", + "calendar_subscribe_body": "Ein privater Kalenderlink mit Ihren Ausleihen vom Beginn bis zur Rückgabefrist, abholbereiten Büchern, vorgemerkten Ausleihen und Ihren Vormerkungen. Kalender-Apps aktualisieren ihn selbst.", + "calendar_status_disabled": "Der Kalenderlink ist ausgeschaltet.", + "calendar_enable": "Kalenderlink erstellen", + "calendar_created_on": "Erstellt am %1$s", + "calendar_copy_link": "Link kopieren", + "calendar_link_copied": "Link kopiert", + "calendar_open_app": "In einer Kalender-App öffnen", + "calendar_hint_google": "Google Kalender: calendar.google.com am Computer öffnen, Weitere Kalender, Per URL wählen und den Link einfügen.", + "calendar_regenerate": "Link neu erzeugen", + "calendar_regenerate_confirm": "Der aktuelle Link funktioniert sofort nicht mehr: Abonnierte Kalender werden erst wieder aktualisiert, wenn der neue Link hinzugefügt wird.", + "calendar_disable": "Link ausschalten", + "calendar_disable_confirm": "Kalender, die diesen Link abonniert haben, erhalten keine Ausleihen mehr.", + "calendar_disabled": "Kalenderlink ausgeschaltet", + "calendar_link_ready": "Neuer Kalenderlink bereit", + "calendar_error": "Der Kalenderlink konnte nicht geladen werden.", + "staff_title": "Adminbereich", + "staff_section_add": "Zum Katalog hinzufügen", + "staff_section_manage": "Verwalten", + "staff_scan": "ISBN scannen", + "staff_scan_subtitle": "Barcode-Scanner mit Stapelmodus", + "staff_add_manual": "Manuell hinzufügen", + "staff_add_manual_subtitle": "Für Medien ohne ISBN: Noten, Broschüren, Objekte", + "staff_books": "Bücher", + "staff_authors": "Autoren", + "staff_publishers": "Verlage", + "staff_genres": "Genres", + "staff_shelves": "Regale", + "staff_forbidden": "Diese Aktion ist dem Bibliothekspersonal vorbehalten.", + "staff_error_load": "Die Daten konnten nicht geladen werden.", + "staff_error_save": "Speichern fehlgeschlagen. Bitte erneut versuchen.", + "staff_error_busy": "Der Katalog ist gerade mit einer anderen Speicherung beschäftigt. Bitte gleich erneut versuchen.", + "staff_error_offline": "Keine Verbindung. Änderungen am Katalog benötigen den Server.", + "staff_cd_decrease": "Verringern", + "staff_cd_increase": "Erhöhen", + "staff_option_none": "Keine Angabe", + "staff_entity_new": "Neu anlegen: %1$s", + "staff_entity_new_chip": "%1$s (neu)", + "staff_entity_use_existing": "Vorhandenen verwenden", + "staff_picker_empty": "Keine Treffer.", + "staff_books_search": "Titel, Autor oder ISBN suchen", + "staff_books_empty": "Keine Datensätze gefunden", + "staff_copies_count": "Verfügbar: %1$d von %2$d", + "staff_copies_total": "Exemplare im Katalog: %1$d", + "staff_book_new": "Neuer Datensatz", + "staff_book_edit": "Datensatz bearbeiten", + "staff_book_save": "Speichern", + "staff_book_saved": "Datensatz gespeichert", + "staff_book_delete": "Datensatz löschen", + "staff_book_delete_confirm": "„%1$s“ löschen? Der Datensatz verschwindet aus dem Katalog und seine Kennungen werden wieder frei.", + "staff_book_deleted": "Datensatz gelöscht", + "staff_book_delete_active_circulation": "Dieser Titel hat aktive Ausleihen oder Vormerkungen und kann nicht gelöscht werden.", + "staff_book_duplicate_title": "Bereits im Katalog", + "staff_book_duplicate_body": "Es gibt bereits einen Datensatz mit dieser Kennung:", + "staff_change_identifier": "Kennung ändern", + "staff_form_fix_errors": "Bitte die markierten Felder prüfen.", + "staff_field_required": "Pflichtfeld", + "staff_field_invalid": "Ungültig", + "staff_identifier_invalid": "Keine gültige ISBN oder EAN", + "staff_lookup_failed_manual": "Die Online-Suche hat nicht geantwortet: Datensatz bitte manuell ausfüllen.", + "staff_section_cover": "Cover", + "staff_section_identity": "Titel und Kennungen", + "staff_section_people": "Autoren und Verlage", + "staff_section_classification": "Klassifikation", + "staff_section_content": "Inhalt und Ausgabe", + "staff_section_holdings": "Exemplare und Standort", + "staff_section_acquisition": "Erwerbung und Notizen", + "staff_field_title": "Titel *", + "staff_field_subtitle": "Untertitel", + "staff_field_isbn13": "ISBN-13", + "staff_field_isbn10": "ISBN-10", + "staff_field_ean": "EAN", + "staff_field_issn": "ISSN", + "staff_add_author": "Autor hinzufügen", + "staff_add_publisher": "Verlag hinzufügen", + "staff_field_genre": "Genre", + "staff_field_subgenre": "Untergenre", + "staff_field_media_type": "Medientyp", + "staff_field_language": "Sprache", + "staff_field_format": "Format", + "staff_field_dewey": "Dewey-Klassifikation", + "staff_field_description": "Beschreibung", + "staff_field_keywords": "Schlagwörter", + "staff_field_year": "Jahr", + "staff_field_pages": "Seiten", + "staff_field_pub_date": "Erscheinungsdatum", + "staff_field_edition": "Ausgabe", + "staff_field_place": "Erscheinungsort", + "staff_field_series": "Reihe", + "staff_field_series_number": "Nummer", + "staff_copies": "Exemplare", + "staff_copies_to_add": "Hinzuzufügende Exemplare", + "staff_add_copies": "Exemplare hinzufügen", + "staff_copies_added": "Exemplare hinzugefügt", + "staff_shelf": "Regal", + "staff_level": "Regalboden", + "staff_level_number": "Boden %1$d", + "staff_position_auto": "Die Position wird automatisch vergeben (nächster freier Platz).", + "staff_position_auto_label": "Nächste freie Position: %1$s", + "staff_field_acquisition_date": "Erwerbungsdatum", + "staff_field_acquisition_type": "Erwerbungsart", + "staff_field_price": "Preis", + "staff_field_inventory_base": "Inventarnummer", + "staff_field_notes": "Notizen", + "staff_cover_from_url": "Von URL", + "staff_cover_take_photo": "Foto aufnehmen", + "staff_cover_pick": "Bild auswählen", + "staff_cover_url_label": "Bildadresse", + "staff_cover_pending": "Neues Cover, wird beim Speichern übernommen.", + "staff_cover_discard": "Verwerfen", + "staff_cover_failed": "Gespeichert, aber das Cover konnte nicht hochgeladen werden. Zum erneuten Versuch noch einmal speichern.", + "staff_camera_unavailable": "Die Kamera ist auf diesem Gerät nicht verfügbar.", + "staff_records_empty": "Noch keine Einträge", + "staff_record_new": "Neu", + "staff_record_edit": "Bearbeiten", + "staff_record_saved": "Gespeichert", + "staff_record_duplicate_title": "Existiert bereits", + "staff_record_duplicate_body": "„%1$s“ ist bereits im Katalog. Stattdessen den vorhandenen Datensatz bearbeiten?", + "staff_field_name": "Name *", + "staff_field_pseudonym": "Pseudonym", + "staff_field_born": "Geboren", + "staff_field_died": "Gestorben", + "staff_field_nationality": "Nationalität", + "staff_field_website": "Website", + "staff_field_gnd": "GND-Kennung", + "staff_field_biography": "Biografie", + "staff_field_address": "Adresse", + "staff_field_phone": "Telefon", + "staff_field_email": "E-Mail", + "staff_genre_add": "Genre hinzufügen", + "staff_subgenre_add_to": "Untergenre zu %1$s hinzufügen", + "staff_genre_exists": "Dieses Genre existiert bereits.", + "staff_shelf_add": "Regal hinzufügen", + "staff_level_add": "Regalboden hinzufügen", + "staff_level_add_to": "Regalboden zu %1$s hinzufügen", + "staff_shelf_exists": "Es gibt bereits ein Regal mit diesem Code.", + "staff_level_exists": "Dieses Regal hat bereits einen Boden mit dieser Nummer.", + "staff_shelf_no_levels": "Noch keine Regalböden", + "staff_field_shelf_code": "Code *", + "staff_field_level_number": "Bodennummer *", + "staff_scan_hint": "Den Barcode in den Rahmen halten. Jeder Code wird zweimal gelesen, bevor er übernommen wird.", + "staff_scan_manual_label": "ISBN oder EAN eingeben", + "staff_scan_lookup": "Suchen", + "staff_scan_looking": "Wird gesucht…", + "staff_scan_permission_rationale": "Die Kamera liest die Barcodes der Bücher. Die ISBN kann auch unten eingegeben werden.", + "staff_scan_permission_allow": "Kamera erlauben", + "staff_scan_permission_denied": "Der Kamerazugriff ist für Pinakes ausgeschaltet. In den Einstellungen aktivieren oder die ISBN unten eingeben.", + "staff_scan_permission_settings": "Einstellungen öffnen", + "staff_scan_torch_on": "Licht einschalten", + "staff_scan_torch_off": "Licht ausschalten", + "staff_scan_settings": "Stapeleinstellungen", + "staff_scan_settings_hint": "Gilt für jeden Titel, der in dieser Sitzung bestätigt wird.", + "staff_scan_batch_title": "Stapelmodus", + "staff_scan_batch_copies": "Exemplare: %1$d", + "staff_scan_no_location": "Kein Standort", + "staff_scan_auto_on": "automatische Bestätigung an", + "staff_scan_auto_off": "jeden Titel bestätigen", + "staff_scan_auto_confirm": "Automatisch bestätigen", + "staff_scan_auto_confirm_hint": "Gefundene Titel ohne Vorschau hinzufügen. Bei Duplikaten und unbekannten Codes wird weiterhin nachgefragt.", + "staff_scan_session_count": "Hinzugefügt: %1$d", + "staff_scan_added": "Zum Katalog hinzugefügt", + "staff_scan_added_list": "In dieser Sitzung hinzugefügt", + "staff_scan_added_new": "Neuer Datensatz · Exemplare: %1$d", + "staff_scan_added_copies": "Hinzugefügte Exemplare: %1$d", + "staff_scan_confirm": "Zum Katalog hinzufügen", + "staff_scan_edit_details": "Zuerst Details bearbeiten", + "staff_scan_skip": "Überspringen", + "staff_scan_duplicate": "Bereits im Katalog", + "staff_scan_open_existing": "Vorhandenen öffnen", + "staff_scan_add_copy": "Exemplare hinzufügen", + "staff_scan_not_found": "Online nicht gefunden", + "staff_scan_not_found_body": "Keine Quelle kennt %1$s. Der Datensatz kann manuell angelegt werden.", + "staff_scan_not_found_ean": "%1$s ist keine Buch-ISBN und keiner Quelle bekannt. Der Datensatz kann manuell angelegt werden.", + "staff_scan_not_found_manual": "Keine Quelle kennt diese Kennung: Datensatz bitte manuell ausfüllen.", + "staff_scan_create_manual": "Manuell anlegen", + "staff_scan_rescan": "Erneut scannen", + "staff_scrape_unavailable": "Die Suchdienste sind gerade nicht erreichbar.", + "staff_scan_offline_title": "Scannen braucht eine Verbindung", + "staff_scan_offline_subtitle": "Jeder Code wird auf dem Bibliotheksserver gesucht. Bitte erneut verbinden und wiederholen.", + "staff_level_required": "Bitte einen Regalboden wählen oder das Regal auf „Keine Angabe“ setzen.", + "offline_generic": "Sie sind offline. Bitte die Verbindung prüfen und erneut versuchen.", + "offline_no_copy_title": "Sie sind offline", + "offline_no_copy": "Auf dem Gerät ist noch keine Kopie gespeichert. Diese Seite einmal mit Verbindung öffnen, um sie offline verfügbar zu haben.", + "offline_availability_hidden": "Sie sind offline: Verfügbarkeit und Ausleihaktionen sind ausgeblendet, bis die Verbindung zurück ist.", + "staff_search_authors": "Autoren suchen", + "staff_search_publishers": "Verlage suchen", + "staff_search_genres": "Genres suchen", + "staff_record_author": "Autor", + "staff_record_publisher": "Verlag", + "staff_record_books_count": "Bücher im Katalog", + "staff_record_life": "Lebensdaten", + "staff_record_created": "Erstellt", + "staff_record_updated": "Aktualisiert", + "staff_record_no_books": "Noch keine Bücher im Katalog", + "staff_section_circulation": "Ausleihe", + "staff_section_books": "Werkzeuge für Bücher", + "staff_section_plugins": "Plugin-Inhalte", + "staff_circulation_desk": "Ausleihtheke", + "staff_circulation_desk_subtitle": "Anfragen, Abholungen, Ausleihen und Überfälliges", + "staff_scan_copy": "Exemplar scannen", + "staff_scan_copy_subtitle": "Ein Exemplar über sein Etikett zurücknehmen, ausleihen oder finden", + "staff_loan_new": "Neue Ausleihe", + "staff_reservations": "Vormerkungen", + "staff_offline_circulation_hidden": "Die Ausleihtheke braucht eine Verbindung: Sie erscheint wieder, sobald du online bist.", + "staff_series": "Reihen", + "staff_merges": "Dubletten zusammenführen", + "staff_merges_subtitle": "Autoren, Verlage und Genres", + "staff_imports": "Datei importieren", + "staff_imports_subtitle": "CSV oder LibraryThing, mit fortsetzbarem Fortschritt", + "staff_covers": "Cover und Anreicherung", + "staff_covers_subtitle": "Fehlende Cover finden, fehlende Daten ergänzen", + "staff_labels_settings": "Etiketten-Einstellungen", + "staff_labels_settings_subtitle": "Größe und Inhalt der gedruckten Etiketten", + "staff_desiderata": "Spendenangebote", + "staff_desiderata_subtitle": "Angebotene Bücher annehmen, ablehnen und erfassen", + "staff_archives": "Archive", + "staff_archives_subtitle": "Archiveinheiten, Anhänge und Veröffentlichung", + "staff_authorities": "Normdatensätze", + "staff_circulation_actions": "Schnellaktionen", + "staff_circulation_today": "Heute fällig: %1$d · Heute zurückgegeben: %2$d", + "staff_loans_title": "Ausleihen", + "staff_loans_pending": "Anfragen", + "staff_loans_pickup": "Abholbereit", + "staff_loans_scheduled": "Geplant", + "staff_loans_active": "Ausgeliehen", + "staff_loans_overdue": "Überfällig", + "staff_loans_history": "Verlauf", + "staff_loans_all": "Alle Ausleihen", + "staff_loans_search": "Titel, Nutzer, Ausweis oder Exemplarcode", + "staff_loans_empty": "Keine Ausleihen hier", + "staff_select_many": "Mehrere auswählen", + "staff_select_done": "Auswahl beenden", + "staff_selected": "Ausgewählt", + "staff_not_selected": "Nicht ausgewählt", + "staff_selected_count": "Ausgewählt: %1$d", + "staff_bulk_extend": "Verlängern", + "staff_bulk_extend_body": "Ausgewählte Ausleihen: %1$d. Fälligkeit verschieben um:", + "staff_bulk_extend_days": "Tage", + "staff_bulk_extend_days_invalid": "Zwischen 1 und 365 Tagen", + "staff_bulk_extended": "Verlängerte Ausleihen: %1$d · hinzugefügte Tage: %2$d", + "staff_bulk_recalled": "Mahnungen gesendet: %1$d, übersprungen: %2$d", + "staff_loan_title": "Ausleihe", + "staff_loan_borrower": "Nutzer", + "staff_loan_details": "Details der Ausleihe", + "staff_user_card": "Ausweis", + "staff_user_open": "Nutzer öffnen", + "staff_open_book": "Buch öffnen", + "staff_copy_inventory": "Exemplar", + "staff_loan_requested": "Angefragt am", + "staff_loan_loaned": "Ausleihbeginn", + "staff_loan_due": "Fällig am", + "staff_loan_pickup_deadline": "Abholen bis", + "staff_loan_returned_at": "Zurückgegeben am", + "staff_loan_renewals": "Verlängerungen", + "staff_loan_renewals_value": "%1$d von %2$d", + "staff_loan_recalls": "Mahnungen", + "staff_loan_processed_by": "Bearbeitet von", + "staff_loan_days_overdue": "Tage überfällig: %1$d", + "staff_loan_pickup_by": "Abholen bis %1$s", + "staff_loan_starts_on": "Beginnt am %1$s", + "staff_penalty": "Gebühr", + "staff_penalty_invalid": "Ein Betrag wie 12,50 (nur bei verlorenen oder beschädigten Exemplaren)", + "staff_loan_action_approve": "Genehmigen", + "staff_loan_action_reject": "Ablehnen", + "staff_loan_action_confirm_pickup": "Abholung bestätigen", + "staff_loan_action_cancel_pickup": "Abholung stornieren", + "staff_loan_action_return": "Rücknahme", + "staff_loan_action_renew": "Verlängern", + "staff_loan_action_edit": "Ausleihe bearbeiten", + "staff_loan_action_recall": "Mahnung senden", + "staff_loan_action_receipt": "Beleg als PDF", + "staff_loan_action_email_receipt": "Beleg per E-Mail senden", + "staff_loan_approve_body": "Diese Anfrage genehmigen? Der Nutzer wird benachrichtigt.", + "staff_loan_pickup_body": "Der Nutzer ist an der Theke und nimmt das Exemplar jetzt mit?", + "staff_loan_renew_body": "Diese Ausleihe um eine Leihfrist verlängern?", + "staff_loan_reject_body": "Der Nutzer wird benachrichtigt, mit dem Grund, falls du einen angibst.", + "staff_loan_cancel_pickup_body": "Das Exemplar wird für den nächsten Nutzer wieder verfügbar.", + "staff_reason_optional": "Grund (optional)", + "staff_loan_approved": "Anfrage genehmigt", + "staff_loan_rejected": "Anfrage abgelehnt", + "staff_loan_picked_up": "Abholung bestätigt: die Ausleihe läuft", + "staff_loan_pickup_cancelled": "Abholung storniert", + "staff_loan_renewed": "Ausleihe verlängert", + "staff_loan_returned": "Rücknahme erfasst", + "staff_loan_updated": "Ausleihe aktualisiert", + "staff_recall_sent": "Mahnung gesendet", + "staff_receipt_emailed": "Beleg per E-Mail gesendet", + "staff_mail_not_sent": "Die E-Mail konnte nicht gesendet werden. Prüfe die Mail-Einstellungen der Bibliothek.", + "staff_receipt_ready": "Ausleihbeleg", + "staff_download_failed": "Die Datei konnte nicht heruntergeladen werden.", + "staff_open_file": "Öffnen", + "staff_share_file": "Teilen", + "staff_return_outcome": "Das Exemplar geht zurück", + "staff_return_outcome_returned": "Ins Regal", + "staff_return_outcome_maintenance": "In die Wartung", + "staff_return_outcome_restoration": "In die Restaurierung", + "staff_return_outcome_lost": "Verloren", + "staff_return_outcome_damaged": "Beschädigt", + "staff_queue_position": "Warteschlange Nr. %1$d", + "staff_reservation_expires": "Läuft ab am %1$s", + "staff_pick_user": "Nutzer", + "staff_search_users": "Name, E-Mail, Ausweis oder Telefon", + "staff_search_min_chars": "Mindestens 2 Zeichen eingeben.", + "staff_picker_confirm": "Hinzufügen (%1$d)", + "staff_date_pick": "Datum wählen", + "staff_err_not_eligible": "Dieser Nutzer kann gerade nicht ausleihen (gesperrt, Ausweis abgelaufen oder nicht berechtigt).", + "staff_err_not_available": "Für diese Daten ist kein Exemplar verfügbar.", + "staff_err_max_loans": "Dieser Nutzer hat bereits die Höchstzahl an Ausleihen.", + "staff_err_loan_closed": "Diese Ausleihe ist abgeschlossen und kann nicht mehr geändert werden.", + "staff_err_concurrent_retry": "Jemand anderes hat diese Ausleihe gleichzeitig geändert. Bitte erneut versuchen.", + "staff_err_not_returnable": "Diese Ausleihe kann in ihrem aktuellen Zustand nicht zurückgenommen werden.", + "staff_err_max_renewals": "Die Ausleihe wurde bereits so oft wie möglich verlängert.", + "staff_err_loan_overdue": "Eine überfällige Ausleihe kann nicht verlängert werden: zuerst die Rücknahme erfassen.", + "staff_err_loan_not_active": "Nur eine laufende Ausleihe kann verlängert werden.", + "staff_err_past_date": "Die Daten liegen in der Vergangenheit.", + "staff_err_ambiguous": "Mehrere offene Ausleihen passen zu diesem Code: die Ausleihe stattdessen aus der Liste öffnen.", + "staff_err_copy_on_loan": "Dieses Exemplar ist ausgeliehen: zuerst die Rücknahme erfassen.", + "staff_err_copy_held": "Dieses Exemplar ist für einen Nutzer reserviert.", + "staff_err_copy_in_circulation": "Dieses Exemplar ist im Umlauf und kann nicht gelöscht werden.", + "staff_err_copy_has_history": "Dieses Exemplar hat einen Ausleihverlauf: als verloren oder ausgesondert markieren statt löschen.", + "staff_err_no_copies": "Dieses Buch hat keine Exemplare, für die Etiketten gedruckt werden können.", + "staff_err_offer_closed": "Dieses Angebot wurde bereits bearbeitet.", + "staff_err_out_of_order": "Der Import wurde anderswo fortgesetzt: es geht dort weiter, wo der Server steht.", + "staff_err_job_busy": "Dieser Import läuft auf einem anderen Gerät. Gleich noch einmal versuchen.", + "staff_err_gone": "Dieser Import ist nicht mehr verfügbar (fertig, abgebrochen oder abgelaufen).", + "staff_err_upload_rejected": "Der Server hat die Datei abgelehnt (Typ oder Größe).", + "staff_err_feature_disabled": "Dieser Bereich ist auf dem Bibliotheksserver ausgeschaltet.", + "staff_err_rate_limited": "Zu viele Anfragen: eine Minute warten und erneut versuchen.", + "staff_labels_hint": "Maße in Millimetern. Sie gelten für alle Etiketten der Bibliothek, auch im Web.", + "staff_labels_width": "Breite (mm)", + "staff_labels_height": "Höhe (mm)", + "staff_labels_padding": "Innenrand (mm)", + "staff_labels_range": "Zwischen 10 und 100 mm (Rand: 0 bis 20)", + "staff_labels_show_app_name": "Name der Bibliothek", + "staff_labels_show_title": "Titel", + "staff_labels_show_subtitle": "Untertitel", + "staff_labels_show_author_publisher": "Autor und Verlag", + "staff_labels_show_dewey": "Dewey-Klassifikation", + "staff_copy_code": "Exemplarcode", + "staff_copy_code_hint": "Optional: genau dieses Exemplar ausleihen. Leer: der Server wählt ein freies.", + "staff_copy_code_required": "Einen Exemplarcode eingeben", + "staff_copy_code_value": "Exemplar %1$s", + "staff_copy_not_found": "Kein Exemplar mit diesem Code", + "staff_copy_not_found_body": "Kein Exemplar im Katalog trägt den Code %1$s.", + "staff_copy_not_found_isbn": "%1$s sieht wie die ISBN eines Buches aus, nicht wie ein Exemplaretikett. Das Inventaretikett des Exemplars scannen.", + "staff_copy_returned": "Rücknahme erfasst: %1$s", + "staff_copy_returned_count": "Zurück: %1$d", + "staff_scan_copy_hint": "Das Inventaretikett des Exemplars in den Rahmen halten oder den Code unten eingeben.", + "staff_scan_copy_permission": "Die Kamera liest die Exemplaretiketten. Du kannst den Code auch unten eingeben.", + "staff_scan_next": "Nächstes Exemplar scannen", + "staff_loan_new_with_copy": "Neue Ausleihe mit diesem Exemplar", + "staff_loan_open": "Ausleihe öffnen", + "staff_hand_over_now": "Jetzt aushändigen", + "staff_hand_over_now_hint": "Der Nutzer nimmt das Exemplar an der Theke mit: die Ausleihe läuft sofort.", + "staff_hand_over_future": "Eine später beginnende Ausleihe wird geplant und an diesem Tag ausgehändigt.", + "staff_loan_create": "Ausleihe anlegen", + "staff_loan_dates": "Daten", + "staff_loan_dates_hint": "Leerer Beginn: heute. Leeres Fälligkeitsdatum: die übliche Leihfrist der Bibliothek.", + "staff_loan_dates_order": "Das Enddatum liegt vor dem Anfangsdatum.", + "staff_loan_summary_start": "Beginn: %1$s", + "staff_pick_book": "Buch", + "staff_pick_book_placeholder": "Im Katalog suchen", + "staff_pick_user_placeholder": "Nutzer suchen", + "staff_user_not_eligible": "Kann gerade nicht ausleihen: %1$s", + "staff_user_open_loans": "Offene Ausleihen", + "staff_user_status": "Status", + "staff_user_status_active": "Aktiv", + "staff_user_status_suspended": "Gesperrt", + "staff_user_status_expired": "Ausweis abgelaufen", + "staff_user_card_expires": "Ausweis gültig bis", + "staff_reservation_new": "Neue Vormerkung", + "staff_reservation_create": "Vormerkung anlegen", + "staff_reservation_edit": "Daten bearbeiten", + "staff_reservation_cancel": "Vormerkung stornieren", + "staff_reservation_cancel_body": "Die Vormerkung von %1$s stornieren? Der Nächste in der Warteschlange rückt auf.", + "staff_reservation_reactivate": "Reaktivieren", + "staff_reservation_cancelled": "Vormerkung storniert", + "staff_reservation_reactivated": "Vormerkung reaktiviert", + "staff_reservation_updated": "Vormerkung aktualisiert", + "staff_reservation_from": "Gewünscht ab", + "staff_reservation_to": "Gewünscht bis", + "staff_reservation_expiry": "Vormerkung läuft ab", + "staff_reservation_dates_hint": "Daten leer lassen, um sich für das nächste freie Exemplar anzustellen.", + "staff_reservations_active": "Aktiv", + "staff_reservations_completed": "Erledigt", + "staff_reservations_cancelled": "Storniert", + "staff_reservations_all": "Alle", + "staff_reservations_empty": "Keine Vormerkungen", + "staff_reservations_search": "Titel oder Nutzer", + "staff_return_title": "Rücknahme erfassen", + "staff_err_idempotency_conflict": "Der Server hat diese Anfrage schon mit anderen Daten erhalten. Formular prüfen und erneut senden.", + "staff_copy_no_code": "Exemplar ohne Code", + "staff_copy_edit": "Exemplar bearbeiten", + "staff_copy_add_with_code": "Ein Exemplar mit Code hinzufügen", + "staff_copy_code_new_hint": "Leer lassen, damit die Bibliothek es nummeriert.", + "staff_copy_status": "Zustand", + "staff_copy_added": "Exemplar hinzugefügt", + "staff_copy_saved": "Exemplar gespeichert", + "staff_copy_deleted": "Exemplar gelöscht", + "staff_copy_delete": "Exemplar löschen", + "staff_copy_delete_body": "Exemplar %1$s löschen? Nur ein nie ausgeliehenes Exemplar kann gelöscht werden.", + "staff_copy_code_taken": "Der Code %1$s wird bereits von einem anderen Exemplar verwendet.", + "staff_labels_book_pdf": "Buchetikett", + "staff_labels_copies_pdf": "Exemplaretiketten", + "staff_labels_this_copy": "Etikett für dieses Exemplar", + "staff_labels_ready": "Etiketten", + "staff_series_new": "Neue Reihe", + "staff_series_search": "Reihen suchen", + "staff_series_empty": "Noch keine Reihen", + "staff_series_book_count": "Bücher: %1$d", + "staff_series_complete": "Vollständig", + "staff_series_exists": "Eine Reihe „%1$s“ gibt es schon. Öffnen?", + "staff_series_open_existing": "Öffnen", + "staff_series_type": "Typ", + "staff_series_parent": "Gehört zu", + "staff_series_parent_hint": "Name der übergeordneten Reihe (wird angelegt, falls sie fehlt).", + "staff_series_group": "Gruppe", + "staff_series_cycle": "Zyklus", + "staff_series_cycle_order": "Reihenfolge", + "staff_series_rename_hint": "Zum Ändern des Namens „Umbenennen“ verwenden.", + "staff_series_saved": "Reihe gespeichert", + "staff_series_edit": "Details bearbeiten", + "staff_series_rename": "Umbenennen", + "staff_series_renamed": "Reihe umbenannt", + "staff_series_rename_clash": "Eine Reihe „%1$s“ gibt es schon: beide zusammenführen?", + "staff_series_merge": "Mit einer anderen Reihe zusammenführen", + "staff_series_merge_body": "Die Bücher wandern in die gewählte Reihe, dann wird diese Reihe entfernt.", + "staff_series_merge_into": "Mit %1$s zusammenführen", + "staff_series_merged": "Reihen zusammengeführt", + "staff_series_merged_count": "Reihen zusammengeführt. Verschobene Bücher: %1$d", + "staff_series_delete": "Reihe löschen", + "staff_series_delete_body": "„%1$s“ löschen? Ihre Bücher (%2$d) bleiben ohne Reihe im Katalog.", + "staff_series_parent_work": "Übergeordnetes Werk anlegen", + "staff_series_parent_work_body": "Ein Katalogeintrag, der die ganze Reihe zusammenfasst, wie im Web.", + "staff_series_parent_work_create": "Anlegen", + "staff_series_parent_work_created": "Übergeordnetes Werk angelegt", + "staff_series_add_books": "Bücher hinzufügen", + "staff_series_books_added": "Bücher hinzugefügt: %1$d", + "staff_series_books": "Bücher (%1$d)", + "staff_series_children": "Untergeordnete Reihen", + "staff_series_numbers": "Nummern", + "staff_series_number_value": "Nr. %1$s", + "staff_series_no_number": "Ohne Nummer", + "staff_series_number_hint": "Leer löscht die Nummer.", + "staff_series_number_saved": "Nummer gespeichert", + "staff_series_remove_book": "Aus der Reihe entfernen", + "staff_series_book_removed": "Buch aus der Reihe entfernt", + "staff_yes": "Ja", + "staff_more_actions": "Weitere Aktionen", + "staff_expand": "Aufklappen", + "staff_collapse": "Zuklappen", + "staff_merge_pick": "Einträge wählen", + "staff_merge_manual_hint": "Zwei oder mehr gleiche Einträge wählen, dann bestimmen, welcher bleibt.", + "staff_merge_review": "Zusammenführung prüfen", + "staff_merge_suggested": "Mögliche Dubletten", + "staff_merge_no_duplicates": "Keine doppelten Autoren gefunden.", + "staff_merge_author_books": "%1$s · Bücher: %2$d", + "staff_merge_primary_hint": "Der gewählte Eintrag bleibt; die anderen werden zusammengeführt und ihre Bücher verschoben.", + "staff_merge_new_name": "Neuer Name (optional)", + "staff_merge_new_name_hint": "Leer behält den Namen des verbleibenden Eintrags.", + "staff_merge_action": "Zusammenführen", + "staff_merge_done": "Zusammengeführte Einträge: %1$d", + "staff_merge_genre_hint": "Bücher und Untergenres des ersten Genres wandern zum zweiten, dann wird das erste entfernt.", + "staff_merge_genre_source": "Zu entfernendes Genre", + "staff_merge_genre_target": "Verbleibendes Genre", + "staff_merge_genre_pick": "Genre wählen", + "staff_merge_genre_done": "Genres zusammengeführt. Verschobene Bücher: %1$d · verschobene Untergenres: %2$d", + "staff_merge_genre_descendant": "Ein Genre kann nicht mit einem eigenen Untergenre zusammengeführt werden.", + "staff_import_field_authors": "Autoren", + "staff_import_field_contributors": "Weitere Mitwirkende", + "staff_import_field_publisher": "Verlag", + "staff_import_field_genre": "Genre", + "staff_import_field_keywords": "Schlagwörter", + "staff_import_field_description": "Beschreibung", + "staff_import_field_isbn": "ISBN", + "staff_import_field_ean": "EAN", + "staff_import_field_title": "Titel", + "staff_import_field_subtitle": "Untertitel", + "staff_import_field_year": "Jahr", + "staff_import_field_language": "Sprache", + "staff_import_field_edition": "Ausgabe", + "staff_import_field_pages": "Seiten", + "staff_import_field_format": "Format", + "staff_import_field_price": "Preis", + "staff_import_field_series": "Reihe", + "staff_import_field_dewey": "Dewey", + "staff_import_kind_csv": "CSV", + "staff_import_kind_lt": "LibraryThing", + "staff_import_file": "Datei", + "staff_import_choose_file": "Datei wählen", + "staff_import_scraping": "Fehlende Daten online suchen", + "staff_import_scraping_hint": "Langsamer: eine Zeile nach der anderen, jede kann dauern.", + "staff_import_update_fields": "Felder, die bei vorhandenen Büchern aktualisiert werden", + "staff_import_start": "Hochladen und importieren", + "staff_import_example": "Beispiel-CSV", + "staff_import_open_jobs": "Abzuschließende Importe", + "staff_import_history": "Frühere Importe", + "staff_import_progress": "Zeilen: %1$d von %2$d", + "staff_import_when": "Gestartet", + "staff_import_by": "Von", + "staff_import_result": "Ergebnis", + "staff_import_counts": "%1$d neu, %2$d aktualisiert, %3$d fehlgeschlagen", + "staff_import_errors_csv": "Fehler herunterladen", + "staff_import_read_failed": "Die Datei konnte nicht gelesen werden.", + "staff_import_too_big": "Die Datei ist größer als 20 MB.", + "staff_import_wrong_ext_csv": "Eine .csv-Datei wählen.", + "staff_import_wrong_ext_lt": "Einen LibraryThing-Export wählen (.tsv, .csv oder .txt).", + "staff_import_failed": "Der Import konnte nicht fortgesetzt werden.", + "staff_import_status_running": "Import läuft…", + "staff_import_status_paused": "Pausiert", + "staff_import_status_done": "Import abgeschlossen", + "staff_import_status_failed": "Import fehlgeschlagen", + "staff_import_status_cancelled": "Import abgebrochen", + "staff_import_imported": "Neue Bücher", + "staff_import_updated": "Aktualisierte Bücher", + "staff_import_scraped": "Online ergänzt", + "staff_import_authors_created": "Neue Autoren", + "staff_import_publishers_created": "Neue Verlage", + "staff_import_errors": "Fehler", + "staff_import_pause": "Pausieren", + "staff_import_resume": "Fortsetzen", + "staff_import_cancel": "Import abbrechen", + "staff_covers_missing_title": "Fehlende Cover", + "staff_covers_missing_count": "Bücher mit ISBN, aber ohne Cover: %1$d", + "staff_covers_missing_unknown": "Bücher ohne Cover werden gezählt…", + "staff_covers_fetch_missing": "Fehlende Cover holen", + "staff_covers_loop_progress": "%1$d von %2$d · gefunden %3$d · nicht gefunden %4$d", + "staff_covers_rate_wait": "Kurz warten: der Server begrenzt die Cover pro Minute.", + "staff_covers_stop": "Anhalten", + "staff_covers_server_sync": "Alle Cover auf dem Server abgleichen", + "staff_covers_server_sync_body": "Der Server holt alle fehlenden Cover auf einmal. Das kann bis zu zehn Minuten dauern; die App offen lassen.", + "staff_covers_sync_done": "Abgeglichene Cover: %1$d von %2$d", + "staff_long_job_running": "Der Server arbeitet, das kann einige Minuten dauern…", + "staff_enrich_title": "Anreicherung", + "staff_enrich_with_isbn": "Bücher mit ISBN", + "staff_enrich_missing_cover": "Ohne Cover", + "staff_enrich_missing_description": "Ohne Beschreibung", + "staff_enrich_pending": "Noch anzureichern", + "staff_enrich_cron": "Automatisch anreichern", + "staff_enrich_cron_hint": "Der Server ergänzt planmäßig einen Stapel Bücher.", + "staff_enrich_run": "Jetzt einen Stapel ausführen", + "staff_enrich_run_body": "Der Server reichert die nächsten 20 Bücher an. Das kann ein paar Minuten dauern.", + "staff_enrich_done": "Angereicherte Bücher: %1$d von %2$d", + "staff_offers_pending": "Zu prüfen", + "staff_offers_accepted": "Angenommen", + "staff_offers_received": "Erhalten", + "staff_offers_rejected": "Abgelehnt", + "staff_offers_all": "Alle", + "staff_offers_wanted": "Gesuchte Titel", + "staff_offers_search": "Titel, Autor oder Spender", + "staff_offers_empty": "Keine Angebote hier", + "staff_wanted_empty": "Die Bibliothek sucht keinen Titel", + "staff_offer_from": "von %1$s", + "staff_offer_status_pending": "Zu prüfen", + "staff_offer_status_accepted": "Angenommen", + "staff_offer_status_received": "Erhalten", + "staff_offer_status_rejected": "Abgelehnt", + "staff_offer_author": "Autor", + "staff_offer_publisher": "Verlag", + "staff_offer_donor": "Spender", + "staff_offer_for_title": "Für den gesuchten Titel", + "staff_offer_received_into": "Erfasst in", + "staff_offer_accept": "Annehmen", + "staff_offer_reject": "Ablehnen", + "staff_offer_receive": "Erhalten: Exemplar hinzufügen", + "staff_offer_receive_into": "Welcher Datensatz erhält das Exemplar?", + "staff_offer_receive_into_hint": "Dieses Angebot betrifft keinen gesuchten Titel: den Katalogeintrag des Exemplars wählen.", + "staff_offer_accepted": "Angebot angenommen", + "staff_offer_rejected": "Angebot abgelehnt", + "staff_offer_received": "Exemplar in den Katalog aufgenommen", + "staff_offer_delete": "Angebot löschen", + "staff_offer_delete_body": "Das Angebot für „%1$s“ löschen? Das lässt sich nicht rückgängig machen.", + "staff_offer_deleted": "Angebot gelöscht", + "staff_wanted_open_offers_count": "Offene Angebote: %1$d", + "staff_wanted_mark_received": "Erhalten", + "staff_wanted_received": "„%1$s“ erhalten: Exemplar hinzugefügt", + "staff_wanted_open_offers": "Für diesen Titel gibt es offene Angebote: über das Angebot erfassen.", + "staff_archives_search": "Titel oder Signatur", + "staff_archives_filter_all": "Alle", + "staff_archives_filter_published": "Veröffentlicht", + "staff_archives_filter_draft": "Nicht veröffentlicht", + "staff_archives_empty": "Keine Archiveinheiten", + "staff_archive_new": "Neue Archiveinheit", + "staff_archive_edit": "Archiveinheit bearbeiten", + "staff_archive_published": "Veröffentlicht", + "staff_archive_draft": "Nicht veröffentlicht", + "staff_archive_published_hint": "Im öffentlichen Archiv und in der App sichtbar.", + "staff_archive_section_identity": "Identifikation", + "staff_archive_section_dates": "Daten", + "staff_archive_section_description": "Beschreibung", + "staff_archive_section_material": "Material", + "staff_archive_section_links": "Links und Kennungen", + "staff_archive_section_authorities": "Normdaten", + "staff_archive_section_files": "Cover und Dokumente", + "staff_archive_reference_code": "Signatur *", + "staff_archive_institution_code": "Institutionscode", + "staff_archive_level": "Ebene *", + "staff_archive_constructed_title": "Titel *", + "staff_archive_formal_title": "Förmlicher Titel", + "staff_archive_parent_id": "Nummer der übergeordneten Einheit", + "staff_archive_date_start": "Von (Jahr)", + "staff_archive_date_end": "Bis (Jahr)", + "staff_archive_extent": "Umfang", + "staff_archive_scope_content": "Inhalt und Umfang", + "staff_archive_language_codes": "Sprachen (Codes)", + "staff_archive_material": "Spezifisches Material", + "staff_archive_color_mode": "Farbe", + "staff_archive_dimensions": "Maße", + "staff_archive_photographer": "Fotograf", + "staff_archive_publisher": "Verlag", + "staff_archive_collection_name": "Sammlung", + "staff_archive_local_classification": "Lokale Klassifikation", + "staff_archive_iiif": "URL des IIIF-Manifests", + "staff_archive_rights": "URL der Rechteerklärung", + "staff_archive_ark": "ARK-Kennung", + "staff_archive_version_note": "Versionshinweis", + "staff_archive_role": "Rolle der nächsten Normdatei", + "staff_archive_attach_authority": "Normdatensatz verknüpfen", + "staff_archive_attach_role_hint": "Wird verknüpft als: %1$s", + "staff_archive_detach": "%1$s lösen", + "staff_archive_no_cover": "Noch kein Cover.", + "staff_archive_has_cover": "Ein Cover ist gesetzt.", + "staff_archive_upload_cover": "Cover hochladen", + "staff_archive_remove_cover": "Cover entfernen", + "staff_archive_upload_document": "Dokument anhängen", + "staff_archive_remove_document": "Dokument entfernen", + "staff_archive_save_first": "Die Einheit zuerst speichern, um Normdaten, Cover oder Dokumente anzuhängen.", + "staff_archive_created": "Archiveinheit angelegt", + "staff_archive_saved": "Archiveinheit gespeichert", + "staff_archive_duplicate": "Diese Signatur wird in dieser Institution bereits verwendet.", + "staff_archive_cover_uploaded": "Cover hochgeladen", + "staff_archive_cover_removed": "Cover entfernt", + "staff_archive_document_uploaded": "Dokument angehängt", + "staff_archive_document_removed": "Dokument entfernt", + "staff_archive_authority_attached": "Normdatensatz verknüpft", + "staff_archive_authority_detached": "Normdatensatz gelöst", + "staff_archive_delete": "Einheit löschen", + "staff_archive_delete_body": "„%1$s“ und die Anhänge löschen?", + "staff_authorities_search": "Normdaten suchen", + "staff_authorities_empty": "Keine Normdatensätze", + "staff_authority_units": "Einheiten: %1$d", + "staff_authority_new": "Neuer Normdatensatz", + "staff_authority_edit": "Normdatensatz bearbeiten", + "staff_authority_delete": "Normdatensatz löschen", + "staff_authority_delete_body": "„%1$s“ löschen? Er wird von allen Einheiten gelöst.", + "staff_authority_type": "Typ *", + "staff_authority_authorised_form": "Ansetzungsform des Namens *", + "staff_authority_parallel_forms": "Parallelformen", + "staff_authority_other_forms": "Andere Formen", + "staff_authority_identifiers": "Kennungen", + "staff_authority_dates": "Lebens- oder Bestandsdaten", + "staff_authority_history": "Geschichte", + "staff_authority_places": "Orte", + "staff_authority_legal_status": "Rechtsstatus", + "staff_authority_functions": "Funktionen und Tätigkeiten", + "staff_authority_mandates": "Mandate", + "staff_authority_internal_structure": "Interne Struktur", + "staff_authority_general_context": "Allgemeiner Kontext", + "staff_authority_linked_units": "Verknüpfte Einheiten", + "staff_export_librarything": "Für LibraryThing exportieren", + "staff_merge_already_done": "Diese Einträge wurden bereits zusammengeführt.", + "staff_err_cover_download_failed": "Ein Cover wurde gefunden, konnte aber nicht heruntergeladen werden.", + "staff_digital_title": "Digitale Inhalte", + "staff_digital_hint": "Das E-Book oder Hörbuch anhängen, das Leser auf der Buchseite öffnen.", + "staff_digital_ebook": "E-Book hochladen (PDF oder EPUB)", + "staff_digital_audio": "Hörbuch hochladen", + "staff_digital_uploading": "Wird hochgeladen…", + "staff_digital_ebook_done": "E-Book angehängt", + "staff_digital_audio_done": "Hörbuch angehängt", + "staff_digital_failed": "Die Datei konnte nicht angehängt werden.", + "staff_select_all": "Alle auswählen", + "staff_import_status_gone": "Dieser Import wurde anderswo beendet", + "staff_archive_document_too_big": "Das Dokument ist größer als 200 MB.", + "staff_periodicals": "Zeitschriftentheke", + "staff_periodicals_subtitle": "Hefte erfassen und reklamieren, Jahrgänge, Abonnements", + "staff_periodicals_scan_permission": "Die Kamera liest den Barcode des Hefts. Du kannst ihn auch unten eingeben.", + "staff_periodicals_code": "Heft-Barcode oder ISSN", + "staff_periodicals_mastheads": "Zeitschriften", + "staff_periodicals_search": "Zeitschrift suchen", + "staff_periodicals_counts": "Jahrgänge: %1$d · Hefte: %2$d", + "staff_periodicals_open": "Zeitschrift öffnen", + "staff_periodicals_title_only": "Kein Heft trägt diesen Code, er gehört aber zu dieser Zeitschrift.", + "staff_periodicals_ambiguous": "Mehrere Hefte tragen diesen Code", + "staff_periodicals_no_match": "Nichts passt zu diesem Code", + "staff_periodicals_frequency": "Erscheinungsweise", + "staff_periodicals_holdings": "Bestand", + "staff_periodicals_years": "Jahrgänge", + "staff_periodicals_year_counts": "Vorhanden: %1$d von %2$d", + "staff_issue_line": "Nr. %1$s · %2$d", + "staff_issue_number": "Nr. %1$s", + "staff_issue_number_label": "Heftnummer *", + "staff_issue_date": "Erscheinungsdatum", + "staff_issue_receive": "Erhalten", + "staff_issue_claim": "Reklamieren", + "staff_issue_add": "Heft hinzufügen", + "staff_issue_added": "Heft hinzugefügt", + "staff_issue_received": "Nr. %1$s von %2$s erhalten", + "staff_issue_claimed": "Nr. %1$s von %2$s reklamiert", + "staff_issue_status_owned": "Vorhanden", + "staff_issue_status_missing": "Fehlt", + "staff_issue_status_expected": "Erwartet", + "staff_issue_status_lost": "Verloren", + "staff_issue_status_claimed": "Reklamiert", + "staff_issue_status_discarded": "Ausgesondert", + "staff_year_add": "Jahrgang hinzufügen", + "staff_year_added": "Jahrgang hinzugefügt", + "staff_year_label": "Jahr *", + "staff_year_volume": "Band", + "staff_year_bound": "In einem Band gebunden", + "staff_subscriptions": "Abonnements", + "staff_subscription_add": "Abonnement hinzufügen", + "staff_subscription_edit": "Abonnement bearbeiten", + "staff_subscription_saved": "Abonnement gespeichert", + "staff_subscription_until": "Bis %1$s", + "staff_subscription_inactive": "Inaktiv", + "staff_subscription_supplier": "Lieferant *", + "staff_subscription_cost": "Kosten", + "staff_subscription_currency": "Währung", + "staff_subscription_start": "Beginn", + "staff_subscription_end": "Ende", + "staff_subscription_auto_renew": "Automatische Verlängerung", + "staff_subscription_active": "Aktiv", + "staff_clubs": "Lesekreise", + "staff_clubs_subtitle": "Einstellungen, Leseablauf und Mitglieder", + "staff_clubs_empty": "Noch keine Lesekreise", + "staff_club_new": "Neuer Lesekreis", + "staff_club_edit": "Einstellungen bearbeiten", + "staff_club_saved": "Lesekreis gespeichert", + "staff_club_delete": "Lesekreis löschen", + "staff_club_delete_body": "„%1$s“ mit Vorschlägen und Diskussionen löschen?", + "staff_club_rules": "Regeln", + "staff_club_privacy": "Sichtbarkeit", + "staff_club_privacy_public": "Öffentlich", + "staff_club_privacy_private": "Privat", + "staff_club_privacy_invite": "Auf Einladung", + "staff_club_privacy_hidden": "Verborgen", + "staff_club_color": "Farbe", + "staff_club_max_members": "Höchstzahl Mitglieder", + "staff_club_active": "Aktiv", + "staff_club_inactive": "Inaktiv", + "staff_club_moderate": "Vorschläge moderieren", + "staff_club_members_count": "Mitglieder: %1$d", + "staff_club_workflow": "Leseablauf", + "staff_club_workflow_edit": "Ablauf bearbeiten", + "staff_club_workflow_hint": "Mindestens zwei Zustände. Markieren, wo abgestimmt wird, was gerade gelesen wird und was archiviert ist.", + "staff_club_workflow_saved": "Ablauf gespeichert", + "staff_club_state_label": "Zustand", + "staff_club_state_add": "Zustand hinzufügen", + "staff_club_state_new": "Neuer Zustand", + "staff_club_flag_voting": "Abstimmung", + "staff_club_flag_current": "Wird gelesen", + "staff_club_flag_archived": "Archiviert", + "staff_move_up": "Nach oben", + "staff_move_down": "Nach unten", + "staff_club_members": "Mitglieder", + "staff_club_role": "Rolle", + "staff_club_role_owner": "Inhaber", + "staff_club_role_moderator": "Moderator", + "staff_club_role_member": "Mitglied", + "staff_club_role_guest": "Gast", + "staff_club_member_status": "Mitgliedschaft", + "staff_club_member_pending": "Ausstehend", + "staff_club_member_active": "Aktiv", + "staff_club_member_suspended": "Gesperrt", + "staff_club_member_left": "Ausgetreten", + "staff_club_member_banned": "Ausgeschlossen", + "staff_club_member_saved": "Mitglied aktualisiert", + "staff_club_member_add": "Mitglied hinzufügen", + "staff_club_member_add_hint": "Tritt bei als: %1$s", + "staff_club_member_added": "%1$s zum Lesekreis hinzugefügt", + "staff_works": "Werke (FRBR)", + "staff_works_subtitle": "Das Werk hinter Ausgaben und Übersetzungen", + "staff_works_search": "Werke suchen", + "staff_works_empty": "Noch keine Werke", + "staff_work_new": "Neues Werk", + "staff_work_edit": "Werk bearbeiten", + "staff_work_saved": "Werk gespeichert", + "staff_work_delete": "Werk löschen", + "staff_work_delete_body": "„%1$s“ löschen? Die Bücher bleiben ohne Werk im Katalog.", + "staff_work_uniform_title": "Einheitstitel *", + "staff_work_original_title": "Originaltitel", + "staff_work_main_author": "Hauptautor", + "staff_work_created_from": "Entstanden ab (Jahr)", + "staff_work_created_to": "Entstanden bis (Jahr)", + "staff_work_created": "Entstehung", + "staff_work_language": "Sprache", + "staff_work_editions_count": "Ausgaben: %1$d", + "staff_work_expressions": "Expressionen", + "staff_work_books": "Bücher dieses Werks", + "staff_work_attach": "Buch verknüpfen", + "staff_work_detach": "Buch lösen", + "staff_work_book_attached": "Buch mit dem Werk verknüpft", + "staff_work_book_detached": "Buch gelöst", + "staff_expression_add": "Expression hinzufügen", + "staff_expression_edit": "Expression bearbeiten", + "staff_expression_delete": "Expression löschen", + "staff_expression_saved": "Expression gespeichert", + "staff_expression_deleted": "Expression gelöscht", + "staff_expression_type": "Typ", + "staff_expression_text": "Text", + "staff_expression_translation": "Übersetzung", + "staff_expression_revision": "Bearbeitung", + "staff_expression_adaptation": "Adaption", + "staff_expression_critical": "Kritische Ausgabe", + "staff_expression_audio": "Audio", + "staff_expression_other": "Andere", + "staff_field_title_plain": "Titel" } diff --git a/i18n/en.json b/i18n/en.json index bf8264c..24a1131 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -355,6 +355,7 @@ "reviews_empty": "No reviews yet. Be the first!", "reviews_rating_content_desc": "Rating: %1$s out of 5", "reviews_write": "Write a review", + "reviews_borrow_first": "You can review this book once you have borrowed it.", "reviews_others_title": "What other readers say", "reviews_your_review": "Your review", "reviews_edit": "Edit", @@ -627,5 +628,819 @@ "credit_illustrator": "Illustrator", "credit_editor": "Editor", "credit_colorist": "Colorist", - "credit_contributor": "Contributor" + "credit_contributor": "Contributor", + "offline_results": "You are offline: these results come from the copy saved on this device.", + "offline_lists_cached": "You are offline: this is the last list saved on this device. Actions need a connection.", + "offline_actions_unavailable": "Offline copy. Loans, reservations, wishlist and reviews need a connection.", + "offline_availability_last_known": "Last known on %1$s, copies on the shelf: %2$d of %3$d. Check online before you come.", + "offline_availability_unknown": "Availability cannot be checked offline.", + "profile_action_staff": "Admin area", + "profile_action_calendar": "Loans in your calendar", + "calendar_subscribe_title": "Loans in your calendar", + "calendar_subscribe_body": "A private calendar link with your loans, from start to due date, books ready for pickup, booked loans and your reservations. Calendar apps refresh it on their own.", + "calendar_status_disabled": "The calendar link is off.", + "calendar_enable": "Create calendar link", + "calendar_created_on": "Created on %1$s", + "calendar_copy_link": "Copy link", + "calendar_link_copied": "Link copied", + "calendar_open_app": "Open in a calendar app", + "calendar_hint_google": "Google Calendar: open calendar.google.com on a computer, choose Other calendars, From URL, and paste the link.", + "calendar_regenerate": "Regenerate link", + "calendar_regenerate_confirm": "The current link stops working at once: calendars subscribed to it stop updating until you add the new one.", + "calendar_disable": "Turn off link", + "calendar_disable_confirm": "Calendars subscribed to this link stop receiving your loans.", + "calendar_disabled": "Calendar link turned off", + "calendar_link_ready": "New calendar link ready", + "calendar_error": "Could not load the calendar link.", + "staff_title": "Admin area", + "staff_section_add": "Add to the catalogue", + "staff_section_manage": "Manage", + "staff_scan": "Scan ISBN", + "staff_scan_subtitle": "Barcode scanner with batch mode", + "staff_add_manual": "Add manually", + "staff_add_manual_subtitle": "For items without an ISBN: sheet music, pamphlets, objects", + "staff_books": "Books", + "staff_authors": "Authors", + "staff_publishers": "Publishers", + "staff_genres": "Genres", + "staff_shelves": "Shelves", + "staff_forbidden": "This action is reserved for library staff.", + "staff_error_load": "Could not load the data.", + "staff_error_save": "Could not save. Please try again.", + "staff_error_busy": "The catalogue is busy with another save. Try again in a moment.", + "staff_error_offline": "No connection. Changes to the catalogue need the server.", + "staff_cd_decrease": "Decrease", + "staff_cd_increase": "Increase", + "staff_option_none": "None", + "staff_entity_new": "Add new: %1$s", + "staff_entity_new_chip": "%1$s (new)", + "staff_entity_use_existing": "Use existing", + "staff_picker_empty": "No matches.", + "staff_books_search": "Search title, author or ISBN", + "staff_books_empty": "No records found", + "staff_copies_count": "Available: %1$d of %2$d", + "staff_copies_total": "Copies in the catalogue: %1$d", + "staff_book_new": "New record", + "staff_book_edit": "Edit record", + "staff_book_save": "Save", + "staff_book_saved": "Record saved", + "staff_book_delete": "Delete record", + "staff_book_delete_confirm": "Delete “%1$s”? It disappears from the catalogue and its identifiers become free again.", + "staff_book_deleted": "Record deleted", + "staff_book_delete_active_circulation": "This title has active loans or reservations and cannot be deleted.", + "staff_book_duplicate_title": "Already in the catalogue", + "staff_book_duplicate_body": "A record with this identifier already exists:", + "staff_change_identifier": "Change identifier", + "staff_form_fix_errors": "Check the highlighted fields.", + "staff_field_required": "Required", + "staff_field_invalid": "Not valid", + "staff_identifier_invalid": "Not a valid ISBN or EAN", + "staff_lookup_failed_manual": "The online lookup did not answer: fill in the record by hand.", + "staff_section_cover": "Cover", + "staff_section_identity": "Title and identifiers", + "staff_section_people": "Authors and publishers", + "staff_section_classification": "Classification", + "staff_section_content": "Content and edition", + "staff_section_holdings": "Copies and location", + "staff_section_acquisition": "Acquisition and notes", + "staff_field_title": "Title *", + "staff_field_subtitle": "Subtitle", + "staff_field_isbn13": "ISBN-13", + "staff_field_isbn10": "ISBN-10", + "staff_field_ean": "EAN", + "staff_field_issn": "ISSN", + "staff_add_author": "Add author", + "staff_add_publisher": "Add publisher", + "staff_field_genre": "Genre", + "staff_field_subgenre": "Subgenre", + "staff_field_media_type": "Media type", + "staff_field_language": "Language", + "staff_field_format": "Format", + "staff_field_dewey": "Dewey classification", + "staff_field_description": "Description", + "staff_field_keywords": "Keywords", + "staff_field_year": "Year", + "staff_field_pages": "Pages", + "staff_field_pub_date": "Publication date", + "staff_field_edition": "Edition", + "staff_field_place": "Place of publication", + "staff_field_series": "Series", + "staff_field_series_number": "Number", + "staff_copies": "Copies", + "staff_copies_to_add": "Copies to add", + "staff_add_copies": "Add copies", + "staff_copies_added": "Copies added", + "staff_shelf": "Shelf", + "staff_level": "Level", + "staff_level_number": "Level %1$d", + "staff_position_auto": "The position is assigned automatically (next free place).", + "staff_position_auto_label": "Next free position: %1$s", + "staff_field_acquisition_date": "Acquisition date", + "staff_field_acquisition_type": "Acquisition type", + "staff_field_price": "Price", + "staff_field_inventory_base": "Inventory number", + "staff_field_notes": "Notes", + "staff_cover_from_url": "From URL", + "staff_cover_take_photo": "Take photo", + "staff_cover_pick": "Pick image", + "staff_cover_url_label": "Image address", + "staff_cover_pending": "New cover, applied when you save.", + "staff_cover_discard": "Discard", + "staff_cover_failed": "Saved, but the cover could not be uploaded. Save again to retry it.", + "staff_camera_unavailable": "The camera is not available on this device.", + "staff_records_empty": "Nothing here yet", + "staff_record_new": "New", + "staff_record_edit": "Edit", + "staff_record_saved": "Saved", + "staff_record_duplicate_title": "Already exists", + "staff_record_duplicate_body": "“%1$s” is already in the catalogue. Edit the existing record instead?", + "staff_field_name": "Name *", + "staff_field_pseudonym": "Pseudonym", + "staff_field_born": "Born", + "staff_field_died": "Died", + "staff_field_nationality": "Nationality", + "staff_field_website": "Website", + "staff_field_gnd": "GND identifier", + "staff_field_biography": "Biography", + "staff_field_address": "Address", + "staff_field_phone": "Phone", + "staff_field_email": "Email", + "staff_genre_add": "Add genre", + "staff_subgenre_add_to": "Add a subgenre to %1$s", + "staff_genre_exists": "This genre already exists.", + "staff_shelf_add": "Add shelf", + "staff_level_add": "Add level", + "staff_level_add_to": "Add a level to %1$s", + "staff_shelf_exists": "A shelf with this code already exists.", + "staff_level_exists": "This shelf already has a level with that number.", + "staff_shelf_no_levels": "No levels yet", + "staff_field_shelf_code": "Code *", + "staff_field_level_number": "Level number *", + "staff_scan_hint": "Hold the barcode inside the frame. Each code is read twice before it is accepted.", + "staff_scan_manual_label": "Type an ISBN or EAN", + "staff_scan_lookup": "Look up", + "staff_scan_looking": "Looking it up…", + "staff_scan_permission_rationale": "The camera reads book barcodes. You can also type the ISBN below.", + "staff_scan_permission_allow": "Allow camera", + "staff_scan_permission_denied": "Camera access is off for Pinakes. Turn it on in the settings, or type the ISBN below.", + "staff_scan_permission_settings": "Open settings", + "staff_scan_torch_on": "Turn on the light", + "staff_scan_torch_off": "Turn off the light", + "staff_scan_settings": "Batch settings", + "staff_scan_settings_hint": "Applied to every title you confirm in this session.", + "staff_scan_batch_title": "Batch mode", + "staff_scan_batch_copies": "Copies: %1$d", + "staff_scan_no_location": "No location", + "staff_scan_auto_on": "auto-confirm on", + "staff_scan_auto_off": "confirm each title", + "staff_scan_auto_confirm": "Auto-confirm", + "staff_scan_auto_confirm_hint": "Add found titles without the preview step. Duplicates and unknown codes still ask.", + "staff_scan_session_count": "Added: %1$d", + "staff_scan_added": "Added to the catalogue", + "staff_scan_added_list": "Added in this session", + "staff_scan_added_new": "New record · copies: %1$d", + "staff_scan_added_copies": "Copies added: %1$d", + "staff_scan_confirm": "Add to the catalogue", + "staff_scan_edit_details": "Edit details first", + "staff_scan_skip": "Skip", + "staff_scan_duplicate": "Already in the catalogue", + "staff_scan_open_existing": "Open existing", + "staff_scan_add_copy": "Add copies", + "staff_scan_not_found": "Not found online", + "staff_scan_not_found_body": "No source knows %1$s. You can create the record by hand.", + "staff_scan_not_found_ean": "%1$s is not a book ISBN and no source knows it. You can create the record by hand.", + "staff_scan_not_found_manual": "No source knows this identifier: fill in the record by hand.", + "staff_scan_create_manual": "Create manually", + "staff_scan_rescan": "Scan again", + "staff_scrape_unavailable": "The lookup services are not reachable right now.", + "staff_scan_offline_title": "Scanning needs a connection", + "staff_scan_offline_subtitle": "Each code is looked up on the library server. Reconnect and try again.", + "staff_level_required": "Choose a level for this shelf, or set the shelf to none.", + "offline_generic": "You are offline. Check your connection and try again.", + "offline_no_copy_title": "You are offline", + "offline_no_copy": "There is no copy saved on this device yet. Open this page once while connected to keep it available offline.", + "offline_availability_hidden": "You are offline: availability and loan actions are hidden until the connection is back.", + "staff_search_authors": "Search authors", + "staff_search_publishers": "Search publishers", + "staff_search_genres": "Search genres", + "staff_record_author": "Author", + "staff_record_publisher": "Publisher", + "staff_record_books_count": "Books in the catalogue", + "staff_record_life": "Life", + "staff_record_created": "Created", + "staff_record_updated": "Updated", + "staff_record_no_books": "No books in the catalogue yet", + "staff_section_circulation": "Circulation", + "staff_section_books": "Book tools", + "staff_section_plugins": "Plugin content", + "staff_circulation_desk": "Circulation desk", + "staff_circulation_desk_subtitle": "Requests, pickups, loans and overdue items", + "staff_scan_copy": "Scan a copy", + "staff_scan_copy_subtitle": "Return, lend or look up a copy by its label", + "staff_loan_new": "New loan", + "staff_reservations": "Reservations", + "staff_offline_circulation_hidden": "The circulation desk needs a connection: it comes back as soon as you are online.", + "staff_series": "Series", + "staff_merges": "Merge duplicates", + "staff_merges_subtitle": "Authors, publishers and genres", + "staff_imports": "Import a file", + "staff_imports_subtitle": "CSV or LibraryThing, with resumable progress", + "staff_covers": "Covers and enrichment", + "staff_covers_subtitle": "Find missing covers, fill in missing data", + "staff_labels_settings": "Label settings", + "staff_labels_settings_subtitle": "Size and content of printed labels", + "staff_desiderata": "Donation offers", + "staff_desiderata_subtitle": "Accept, reject and receive offered books", + "staff_archives": "Archives", + "staff_archives_subtitle": "Archival units, attachments and publishing", + "staff_authorities": "Authority records", + "staff_circulation_actions": "Quick actions", + "staff_circulation_today": "Due today: %1$d · Returned today: %2$d", + "staff_loans_title": "Loans", + "staff_loans_pending": "Requests", + "staff_loans_pickup": "To pick up", + "staff_loans_scheduled": "Scheduled", + "staff_loans_active": "On loan", + "staff_loans_overdue": "Overdue", + "staff_loans_history": "History", + "staff_loans_all": "All loans", + "staff_loans_search": "Title, borrower, card or copy code", + "staff_loans_empty": "No loans here", + "staff_select_many": "Select several", + "staff_select_done": "Stop selecting", + "staff_selected": "Selected", + "staff_not_selected": "Not selected", + "staff_selected_count": "Selected: %1$d", + "staff_bulk_extend": "Extend", + "staff_bulk_extend_body": "Selected loans: %1$d. Move their due date forward by:", + "staff_bulk_extend_days": "Days", + "staff_bulk_extend_days_invalid": "Between 1 and 365 days", + "staff_bulk_extended": "Loans extended: %1$d · days added: %2$d", + "staff_bulk_recalled": "Reminders sent: %1$d, skipped: %2$d", + "staff_loan_title": "Loan", + "staff_loan_borrower": "Borrower", + "staff_loan_details": "Loan details", + "staff_user_card": "Card", + "staff_user_open": "Open the user", + "staff_open_book": "Open the book", + "staff_copy_inventory": "Copy", + "staff_loan_requested": "Requested", + "staff_loan_loaned": "Loan start", + "staff_loan_due": "Due", + "staff_loan_pickup_deadline": "Pick up by", + "staff_loan_returned_at": "Returned", + "staff_loan_renewals": "Renewals", + "staff_loan_renewals_value": "%1$d of %2$d", + "staff_loan_recalls": "Reminders", + "staff_loan_processed_by": "Handled by", + "staff_loan_days_overdue": "Days overdue: %1$d", + "staff_loan_pickup_by": "Pick up by %1$s", + "staff_loan_starts_on": "Starts on %1$s", + "staff_penalty": "Penalty", + "staff_penalty_invalid": "An amount such as 12.50 (lost or damaged copies only)", + "staff_loan_action_approve": "Approve", + "staff_loan_action_reject": "Reject", + "staff_loan_action_confirm_pickup": "Confirm pickup", + "staff_loan_action_cancel_pickup": "Cancel pickup", + "staff_loan_action_return": "Return", + "staff_loan_action_renew": "Renew", + "staff_loan_action_edit": "Edit loan", + "staff_loan_action_recall": "Send a reminder", + "staff_loan_action_receipt": "Receipt PDF", + "staff_loan_action_email_receipt": "Email the receipt", + "staff_loan_approve_body": "Approve this request? The borrower is notified.", + "staff_loan_pickup_body": "The borrower is at the desk and takes the copy now?", + "staff_loan_renew_body": "Extend this loan by one loan period?", + "staff_loan_reject_body": "The borrower is notified, with the reason if you give one.", + "staff_loan_cancel_pickup_body": "The copy becomes available again for the next borrower.", + "staff_reason_optional": "Reason (optional)", + "staff_loan_approved": "Request approved", + "staff_loan_rejected": "Request rejected", + "staff_loan_picked_up": "Pickup confirmed: the loan is running", + "staff_loan_pickup_cancelled": "Pickup cancelled", + "staff_loan_renewed": "Loan renewed", + "staff_loan_returned": "Return recorded", + "staff_loan_updated": "Loan updated", + "staff_recall_sent": "Reminder sent", + "staff_receipt_emailed": "Receipt sent by email", + "staff_mail_not_sent": "The email could not be sent. Check the library’s mail settings.", + "staff_receipt_ready": "Loan receipt", + "staff_download_failed": "The file could not be downloaded.", + "staff_open_file": "Open", + "staff_share_file": "Share", + "staff_return_outcome": "The copy goes back", + "staff_return_outcome_returned": "On the shelf", + "staff_return_outcome_maintenance": "To maintenance", + "staff_return_outcome_restoration": "To restoration", + "staff_return_outcome_lost": "Lost", + "staff_return_outcome_damaged": "Damaged", + "staff_queue_position": "Queue #%1$d", + "staff_reservation_expires": "Expires %1$s", + "staff_pick_user": "Borrower", + "staff_search_users": "Name, email, card or phone", + "staff_search_min_chars": "Type at least 2 characters.", + "staff_picker_confirm": "Add (%1$d)", + "staff_date_pick": "Pick a date", + "staff_err_not_eligible": "This user cannot borrow right now (suspended, card expired or not allowed).", + "staff_err_not_available": "No copy is available for those dates.", + "staff_err_max_loans": "This user already has the maximum number of loans.", + "staff_err_loan_closed": "This loan is closed and can no longer be changed.", + "staff_err_concurrent_retry": "Someone else changed this loan at the same time. Try again.", + "staff_err_not_returnable": "This loan cannot be returned in its current state.", + "staff_err_max_renewals": "The loan has already been renewed the maximum number of times.", + "staff_err_loan_overdue": "An overdue loan cannot be renewed: record the return first.", + "staff_err_loan_not_active": "Only a running loan can be renewed.", + "staff_err_past_date": "The dates are in the past.", + "staff_err_ambiguous": "Several open loans match this code: open the loan from the list instead.", + "staff_err_copy_on_loan": "This copy is on loan: record the return first.", + "staff_err_copy_held": "This copy is held for a borrower.", + "staff_err_copy_in_circulation": "This copy is in circulation and cannot be deleted.", + "staff_err_copy_has_history": "This copy has loan history: set it to lost or withdrawn instead of deleting it.", + "staff_err_no_copies": "This book has no copies to print labels for.", + "staff_err_offer_closed": "This offer has already been handled.", + "staff_err_out_of_order": "The import moved on elsewhere: continuing from where the server is.", + "staff_err_job_busy": "This import is running on another device. Try again in a moment.", + "staff_err_gone": "This import is no longer available (finished, cancelled or expired).", + "staff_err_upload_rejected": "The server refused the file (type or size).", + "staff_err_feature_disabled": "This area is turned off on the library server.", + "staff_err_rate_limited": "Too many requests: wait a minute and try again.", + "staff_labels_hint": "Sizes in millimetres. They apply to every label the library prints, on the web too.", + "staff_labels_width": "Width (mm)", + "staff_labels_height": "Height (mm)", + "staff_labels_padding": "Inner margin (mm)", + "staff_labels_range": "Between 10 and 100 mm (margin: 0 to 20)", + "staff_labels_show_app_name": "Library name", + "staff_labels_show_title": "Title", + "staff_labels_show_subtitle": "Subtitle", + "staff_labels_show_author_publisher": "Author and publisher", + "staff_labels_show_dewey": "Dewey classification", + "staff_copy_code": "Copy code", + "staff_copy_code_hint": "Optional: lend this exact copy. Empty: the server picks a free one.", + "staff_copy_code_required": "Type a copy code", + "staff_copy_code_value": "Copy %1$s", + "staff_copy_not_found": "No copy with this code", + "staff_copy_not_found_body": "No copy in the catalogue carries the code %1$s.", + "staff_copy_not_found_isbn": "%1$s looks like a book ISBN, not a copy label. Scan the inventory label of the copy.", + "staff_copy_returned": "Return recorded: %1$s", + "staff_copy_returned_count": "Returned: %1$d", + "staff_scan_copy_hint": "Hold the copy’s inventory label inside the frame, or type its code below.", + "staff_scan_copy_permission": "The camera reads the copy labels. You can also type the code below.", + "staff_scan_next": "Scan the next copy", + "staff_loan_new_with_copy": "New loan with this copy", + "staff_loan_open": "Open the loan", + "staff_hand_over_now": "Hand over now", + "staff_hand_over_now_hint": "The borrower takes the copy at the desk: the loan starts running at once.", + "staff_hand_over_future": "A loan starting later is scheduled and handed over on that day.", + "staff_loan_create": "Create the loan", + "staff_loan_dates": "Dates", + "staff_loan_dates_hint": "Empty start: today. Empty due date: the library’s standard loan period.", + "staff_loan_dates_order": "The end date is before the start date.", + "staff_loan_summary_start": "Starts on %1$s", + "staff_pick_book": "Book", + "staff_pick_book_placeholder": "Search the catalogue", + "staff_pick_user_placeholder": "Search a borrower", + "staff_user_not_eligible": "Cannot borrow now: %1$s", + "staff_user_open_loans": "Open loans", + "staff_user_status": "Status", + "staff_user_status_active": "Active", + "staff_user_status_suspended": "Suspended", + "staff_user_status_expired": "Card expired", + "staff_user_card_expires": "Card valid until", + "staff_reservation_new": "New reservation", + "staff_reservation_create": "Create the reservation", + "staff_reservation_edit": "Edit dates", + "staff_reservation_cancel": "Cancel reservation", + "staff_reservation_cancel_body": "Cancel the reservation of %1$s? The next one in the queue moves up.", + "staff_reservation_reactivate": "Reactivate", + "staff_reservation_cancelled": "Reservation cancelled", + "staff_reservation_reactivated": "Reservation reactivated", + "staff_reservation_updated": "Reservation updated", + "staff_reservation_from": "Wanted from", + "staff_reservation_to": "Wanted until", + "staff_reservation_expiry": "Reservation expires", + "staff_reservation_dates_hint": "Leave the dates empty to queue for the next free copy.", + "staff_reservations_active": "Active", + "staff_reservations_completed": "Completed", + "staff_reservations_cancelled": "Cancelled", + "staff_reservations_all": "All", + "staff_reservations_empty": "No reservations", + "staff_reservations_search": "Title or borrower", + "staff_return_title": "Record the return", + "staff_err_idempotency_conflict": "The server saw this request with different data. Check the form and send it again.", + "staff_copy_no_code": "Copy without a code", + "staff_copy_edit": "Edit copy", + "staff_copy_add_with_code": "Add a copy with its code", + "staff_copy_code_new_hint": "Leave empty to let the library number it.", + "staff_copy_status": "State", + "staff_copy_added": "Copy added", + "staff_copy_saved": "Copy saved", + "staff_copy_deleted": "Copy deleted", + "staff_copy_delete": "Delete copy", + "staff_copy_delete_body": "Delete copy %1$s? Only a copy that never circulated can be deleted.", + "staff_copy_code_taken": "The code %1$s is already used by another copy.", + "staff_labels_book_pdf": "Book label", + "staff_labels_copies_pdf": "Copy labels", + "staff_labels_this_copy": "Label for this copy", + "staff_labels_ready": "Labels", + "staff_series_new": "New series", + "staff_series_search": "Search series", + "staff_series_empty": "No series yet", + "staff_series_book_count": "Books: %1$d", + "staff_series_complete": "Complete", + "staff_series_exists": "A series called “%1$s” already exists. Open it?", + "staff_series_open_existing": "Open it", + "staff_series_type": "Type", + "staff_series_parent": "Part of", + "staff_series_parent_hint": "Name of the larger series (created if missing).", + "staff_series_group": "Group", + "staff_series_cycle": "Cycle", + "staff_series_cycle_order": "Order", + "staff_series_rename_hint": "To change the name, use Rename.", + "staff_series_saved": "Series saved", + "staff_series_edit": "Edit details", + "staff_series_rename": "Rename", + "staff_series_renamed": "Series renamed", + "staff_series_rename_clash": "A series called “%1$s” already exists: rename into it and merge the two?", + "staff_series_merge": "Merge into another series", + "staff_series_merge_body": "Its books move to the series you pick, then this series is removed.", + "staff_series_merge_into": "Merge into %1$s", + "staff_series_merged": "Series merged", + "staff_series_merged_count": "Series merged. Books moved: %1$d", + "staff_series_delete": "Delete series", + "staff_series_delete_body": "Delete “%1$s”? Its books (%2$d) stay in the catalogue without a series.", + "staff_series_parent_work": "Create the parent work", + "staff_series_parent_work_body": "A catalogue record that groups the whole series, as on the web.", + "staff_series_parent_work_create": "Create", + "staff_series_parent_work_created": "Parent work created", + "staff_series_add_books": "Add books", + "staff_series_books_added": "Books added: %1$d", + "staff_series_books": "Books (%1$d)", + "staff_series_children": "Series inside", + "staff_series_numbers": "Numbers", + "staff_series_number_value": "No. %1$s", + "staff_series_no_number": "No number", + "staff_series_number_hint": "Empty clears the number.", + "staff_series_number_saved": "Number saved", + "staff_series_remove_book": "Remove from series", + "staff_series_book_removed": "Book removed from the series", + "staff_yes": "Yes", + "staff_more_actions": "More actions", + "staff_expand": "Expand", + "staff_collapse": "Collapse", + "staff_merge_pick": "Pick records", + "staff_merge_manual_hint": "Pick two or more records that are the same, then choose which one stays.", + "staff_merge_review": "Review the merge", + "staff_merge_suggested": "Possible duplicates", + "staff_merge_no_duplicates": "No duplicate authors found.", + "staff_merge_author_books": "%1$s · books: %2$d", + "staff_merge_primary_hint": "The selected record stays; the others are merged into it and their books move.", + "staff_merge_new_name": "New name (optional)", + "staff_merge_new_name_hint": "Empty keeps the name of the record that stays.", + "staff_merge_action": "Merge", + "staff_merge_done": "Records merged: %1$d", + "staff_merge_genre_hint": "The books and subgenres of the first genre move to the second, then the first is removed.", + "staff_merge_genre_source": "Genre to remove", + "staff_merge_genre_target": "Genre that stays", + "staff_merge_genre_pick": "Pick a genre", + "staff_merge_genre_done": "Genres merged. Books moved: %1$d · subgenres moved: %2$d", + "staff_merge_genre_descendant": "A genre cannot be merged into one of its own subgenres.", + "staff_import_field_authors": "Authors", + "staff_import_field_contributors": "Other contributors", + "staff_import_field_publisher": "Publisher", + "staff_import_field_genre": "Genre", + "staff_import_field_keywords": "Keywords", + "staff_import_field_description": "Description", + "staff_import_field_isbn": "ISBN", + "staff_import_field_ean": "EAN", + "staff_import_field_title": "Title", + "staff_import_field_subtitle": "Subtitle", + "staff_import_field_year": "Year", + "staff_import_field_language": "Language", + "staff_import_field_edition": "Edition", + "staff_import_field_pages": "Pages", + "staff_import_field_format": "Format", + "staff_import_field_price": "Price", + "staff_import_field_series": "Series", + "staff_import_field_dewey": "Dewey", + "staff_import_kind_csv": "CSV", + "staff_import_kind_lt": "LibraryThing", + "staff_import_file": "File", + "staff_import_choose_file": "Choose a file", + "staff_import_scraping": "Look up missing data online", + "staff_import_scraping_hint": "Slower: one row at a time, and each row may take a while.", + "staff_import_update_fields": "Fields to update on books already in the catalogue", + "staff_import_start": "Upload and import", + "staff_import_example": "Example CSV", + "staff_import_open_jobs": "Imports to finish", + "staff_import_history": "Past imports", + "staff_import_progress": "Rows: %1$d of %2$d", + "staff_import_when": "Started", + "staff_import_by": "By", + "staff_import_result": "Result", + "staff_import_counts": "%1$d new, %2$d updated, %3$d failed", + "staff_import_errors_csv": "Download the errors", + "staff_import_read_failed": "The file could not be read.", + "staff_import_too_big": "The file is larger than 20 MB.", + "staff_import_wrong_ext_csv": "Choose a .csv file.", + "staff_import_wrong_ext_lt": "Choose a LibraryThing export (.tsv, .csv or .txt).", + "staff_import_failed": "The import could not continue.", + "staff_import_status_running": "Importing…", + "staff_import_status_paused": "Paused", + "staff_import_status_done": "Import complete", + "staff_import_status_failed": "Import failed", + "staff_import_status_cancelled": "Import cancelled", + "staff_import_imported": "New books", + "staff_import_updated": "Books updated", + "staff_import_scraped": "Completed online", + "staff_import_authors_created": "New authors", + "staff_import_publishers_created": "New publishers", + "staff_import_errors": "Errors", + "staff_import_pause": "Pause", + "staff_import_resume": "Resume", + "staff_import_cancel": "Cancel the import", + "staff_covers_missing_title": "Missing covers", + "staff_covers_missing_count": "Books with an ISBN but no cover: %1$d", + "staff_covers_missing_unknown": "Counting the books without a cover…", + "staff_covers_fetch_missing": "Fetch the missing covers", + "staff_covers_loop_progress": "%1$d of %2$d · found %3$d · not found %4$d", + "staff_covers_rate_wait": "Waiting a moment: the server limits how many covers are fetched per minute.", + "staff_covers_stop": "Stop", + "staff_covers_server_sync": "Sync all covers on the server", + "staff_covers_server_sync_body": "The server fetches every missing cover in one go. It can take up to ten minutes; keep the app open.", + "staff_covers_sync_done": "Covers synced: %1$d of %2$d", + "staff_long_job_running": "Working on the server, this can take a few minutes…", + "staff_enrich_title": "Enrichment", + "staff_enrich_with_isbn": "Books with an ISBN", + "staff_enrich_missing_cover": "Without a cover", + "staff_enrich_missing_description": "Without a description", + "staff_enrich_pending": "Still to enrich", + "staff_enrich_cron": "Enrich automatically", + "staff_enrich_cron_hint": "The server completes a batch of books on its schedule.", + "staff_enrich_run": "Run a batch now", + "staff_enrich_run_body": "The server enriches the next 20 books. It can take a couple of minutes.", + "staff_enrich_done": "Books enriched: %1$d of %2$d", + "staff_offers_pending": "To review", + "staff_offers_accepted": "Accepted", + "staff_offers_received": "Received", + "staff_offers_rejected": "Rejected", + "staff_offers_all": "All", + "staff_offers_wanted": "Wanted titles", + "staff_offers_search": "Title, author or donor", + "staff_offers_empty": "No offers here", + "staff_wanted_empty": "The library is not looking for any title", + "staff_offer_from": "from %1$s", + "staff_offer_status_pending": "To review", + "staff_offer_status_accepted": "Accepted", + "staff_offer_status_received": "Received", + "staff_offer_status_rejected": "Rejected", + "staff_offer_author": "Author", + "staff_offer_publisher": "Publisher", + "staff_offer_donor": "Donor", + "staff_offer_for_title": "For the wanted title", + "staff_offer_received_into": "Received into", + "staff_offer_accept": "Accept", + "staff_offer_reject": "Reject", + "staff_offer_receive": "Received: add the copy", + "staff_offer_receive_into": "Which record gets the copy?", + "staff_offer_receive_into_hint": "This offer is not for a wanted title: pick the catalogue record the copy belongs to.", + "staff_offer_accepted": "Offer accepted", + "staff_offer_rejected": "Offer rejected", + "staff_offer_received": "Copy added to the catalogue", + "staff_offer_delete": "Delete offer", + "staff_offer_delete_body": "Delete the offer of “%1$s”? This cannot be undone.", + "staff_offer_deleted": "Offer deleted", + "staff_wanted_open_offers_count": "Open offers: %1$d", + "staff_wanted_mark_received": "Received", + "staff_wanted_received": "“%1$s” received: copy added", + "staff_wanted_open_offers": "This title has open offers: receive it from the offer instead.", + "staff_archives_search": "Title or reference code", + "staff_archives_filter_all": "All", + "staff_archives_filter_published": "Published", + "staff_archives_filter_draft": "Not published", + "staff_archives_empty": "No archival units", + "staff_archive_new": "New archival unit", + "staff_archive_edit": "Edit archival unit", + "staff_archive_published": "Published", + "staff_archive_draft": "Not published", + "staff_archive_published_hint": "Visible in the public archive and in the app.", + "staff_archive_section_identity": "Identity", + "staff_archive_section_dates": "Dates", + "staff_archive_section_description": "Description", + "staff_archive_section_material": "Material", + "staff_archive_section_links": "Links and identifiers", + "staff_archive_section_authorities": "Authorities", + "staff_archive_section_files": "Cover and documents", + "staff_archive_reference_code": "Reference code *", + "staff_archive_institution_code": "Institution code", + "staff_archive_level": "Level *", + "staff_archive_constructed_title": "Title *", + "staff_archive_formal_title": "Formal title", + "staff_archive_parent_id": "Parent unit number", + "staff_archive_date_start": "From (year)", + "staff_archive_date_end": "To (year)", + "staff_archive_extent": "Extent", + "staff_archive_scope_content": "Scope and content", + "staff_archive_language_codes": "Languages (codes)", + "staff_archive_material": "Specific material", + "staff_archive_color_mode": "Colour", + "staff_archive_dimensions": "Dimensions", + "staff_archive_photographer": "Photographer", + "staff_archive_publisher": "Publisher", + "staff_archive_collection_name": "Collection", + "staff_archive_local_classification": "Local classification", + "staff_archive_iiif": "IIIF manifest URL", + "staff_archive_rights": "Rights statement URL", + "staff_archive_ark": "ARK identifier", + "staff_archive_version_note": "Version note", + "staff_archive_role": "Role of the next authority", + "staff_archive_attach_authority": "Attach an authority", + "staff_archive_attach_role_hint": "It will be attached as: %1$s", + "staff_archive_detach": "Detach %1$s", + "staff_archive_no_cover": "No cover yet.", + "staff_archive_has_cover": "A cover is set.", + "staff_archive_upload_cover": "Upload a cover", + "staff_archive_remove_cover": "Remove the cover", + "staff_archive_upload_document": "Attach a document", + "staff_archive_remove_document": "Remove the document", + "staff_archive_save_first": "Save the unit first to attach authorities, a cover or documents.", + "staff_archive_created": "Archival unit created", + "staff_archive_saved": "Archival unit saved", + "staff_archive_duplicate": "This reference code is already used in this institution.", + "staff_archive_cover_uploaded": "Cover uploaded", + "staff_archive_cover_removed": "Cover removed", + "staff_archive_document_uploaded": "Document attached", + "staff_archive_document_removed": "Document removed", + "staff_archive_authority_attached": "Authority attached", + "staff_archive_authority_detached": "Authority detached", + "staff_archive_delete": "Delete unit", + "staff_archive_delete_body": "Delete “%1$s” and its attachments?", + "staff_authorities_search": "Search authorities", + "staff_authorities_empty": "No authority records", + "staff_authority_units": "Units: %1$d", + "staff_authority_new": "New authority", + "staff_authority_edit": "Edit authority", + "staff_authority_delete": "Delete authority", + "staff_authority_delete_body": "Delete “%1$s”? It is detached from every unit.", + "staff_authority_type": "Type *", + "staff_authority_authorised_form": "Authorised form of name *", + "staff_authority_parallel_forms": "Parallel forms", + "staff_authority_other_forms": "Other forms", + "staff_authority_identifiers": "Identifiers", + "staff_authority_dates": "Dates of existence", + "staff_authority_history": "History", + "staff_authority_places": "Places", + "staff_authority_legal_status": "Legal status", + "staff_authority_functions": "Functions and activities", + "staff_authority_mandates": "Mandates", + "staff_authority_internal_structure": "Internal structure", + "staff_authority_general_context": "General context", + "staff_authority_linked_units": "Linked units", + "staff_export_librarything": "Export for LibraryThing", + "staff_merge_already_done": "These records were already merged.", + "staff_err_cover_download_failed": "A cover was found but could not be downloaded.", + "staff_digital_title": "Digital content", + "staff_digital_hint": "Attach the eBook or the audiobook readers can open from the book page.", + "staff_digital_ebook": "Upload an eBook (PDF or EPUB)", + "staff_digital_audio": "Upload an audiobook", + "staff_digital_uploading": "Uploading…", + "staff_digital_ebook_done": "eBook attached", + "staff_digital_audio_done": "Audiobook attached", + "staff_digital_failed": "The file could not be attached.", + "staff_select_all": "Select all", + "staff_import_status_gone": "This import ended elsewhere", + "staff_archive_document_too_big": "The document is larger than 200 MB.", + "staff_periodicals": "Periodicals desk", + "staff_periodicals_subtitle": "Receive and claim issues, years, subscriptions", + "staff_periodicals_scan_permission": "The camera reads the issue barcode. You can also type it below.", + "staff_periodicals_code": "Issue barcode or ISSN", + "staff_periodicals_mastheads": "Periodicals", + "staff_periodicals_search": "Search a periodical", + "staff_periodicals_counts": "Years: %1$d · issues: %2$d", + "staff_periodicals_open": "Open the periodical", + "staff_periodicals_title_only": "No issue carries this code, but it belongs to this periodical.", + "staff_periodicals_ambiguous": "Several issues carry this code", + "staff_periodicals_no_match": "Nothing matches this code", + "staff_periodicals_frequency": "Frequency", + "staff_periodicals_holdings": "Holdings", + "staff_periodicals_years": "Years", + "staff_periodicals_year_counts": "Owned: %1$d of %2$d", + "staff_issue_line": "No. %1$s · %2$d", + "staff_issue_number": "No. %1$s", + "staff_issue_number_label": "Issue number *", + "staff_issue_date": "Publication date", + "staff_issue_receive": "Received", + "staff_issue_claim": "Claim", + "staff_issue_add": "Add an issue", + "staff_issue_added": "Issue added", + "staff_issue_received": "No. %1$s of %2$s received", + "staff_issue_claimed": "No. %1$s of %2$s claimed", + "staff_issue_status_owned": "Owned", + "staff_issue_status_missing": "Missing", + "staff_issue_status_expected": "Expected", + "staff_issue_status_lost": "Lost", + "staff_issue_status_claimed": "Claimed", + "staff_issue_status_discarded": "Discarded", + "staff_year_add": "Add a year", + "staff_year_added": "Year added", + "staff_year_label": "Year *", + "staff_year_volume": "Volume", + "staff_year_bound": "Bound into one volume", + "staff_subscriptions": "Subscriptions", + "staff_subscription_add": "Add a subscription", + "staff_subscription_edit": "Edit subscription", + "staff_subscription_saved": "Subscription saved", + "staff_subscription_until": "Until %1$s", + "staff_subscription_inactive": "Inactive", + "staff_subscription_supplier": "Supplier *", + "staff_subscription_cost": "Cost", + "staff_subscription_currency": "Currency", + "staff_subscription_start": "Start", + "staff_subscription_end": "End", + "staff_subscription_auto_renew": "Renews automatically", + "staff_subscription_active": "Active", + "staff_clubs": "Book clubs", + "staff_clubs_subtitle": "Settings, reading workflow and members", + "staff_clubs_empty": "No book clubs yet", + "staff_club_new": "New club", + "staff_club_edit": "Edit settings", + "staff_club_saved": "Club saved", + "staff_club_delete": "Delete club", + "staff_club_delete_body": "Delete “%1$s” with its proposals and discussions?", + "staff_club_rules": "Rules", + "staff_club_privacy": "Visibility", + "staff_club_privacy_public": "Public", + "staff_club_privacy_private": "Private", + "staff_club_privacy_invite": "By invitation", + "staff_club_privacy_hidden": "Hidden", + "staff_club_color": "Colour", + "staff_club_max_members": "Maximum members", + "staff_club_active": "Active", + "staff_club_inactive": "Inactive", + "staff_club_moderate": "Moderate the proposals", + "staff_club_members_count": "Members: %1$d", + "staff_club_workflow": "Reading workflow", + "staff_club_workflow_edit": "Edit the workflow", + "staff_club_workflow_hint": "At least two states. Mark where members vote, what is being read now, and what is archived.", + "staff_club_workflow_saved": "Workflow saved", + "staff_club_state_label": "State", + "staff_club_state_add": "Add a state", + "staff_club_state_new": "New state", + "staff_club_flag_voting": "Voting", + "staff_club_flag_current": "Reading now", + "staff_club_flag_archived": "Archived", + "staff_move_up": "Move up", + "staff_move_down": "Move down", + "staff_club_members": "Members", + "staff_club_role": "Role", + "staff_club_role_owner": "Owner", + "staff_club_role_moderator": "Moderator", + "staff_club_role_member": "Member", + "staff_club_role_guest": "Guest", + "staff_club_member_status": "Membership", + "staff_club_member_pending": "Pending", + "staff_club_member_active": "Active", + "staff_club_member_suspended": "Suspended", + "staff_club_member_left": "Left", + "staff_club_member_banned": "Banned", + "staff_club_member_saved": "Member updated", + "staff_club_member_add": "Add a member", + "staff_club_member_add_hint": "They join as: %1$s", + "staff_club_member_added": "%1$s added to the club", + "staff_works": "Works (FRBR)", + "staff_works_subtitle": "The work behind its editions and translations", + "staff_works_search": "Search works", + "staff_works_empty": "No works yet", + "staff_work_new": "New work", + "staff_work_edit": "Edit the work", + "staff_work_saved": "Work saved", + "staff_work_delete": "Delete work", + "staff_work_delete_body": "Delete “%1$s”? Its books stay in the catalogue without a work.", + "staff_work_uniform_title": "Uniform title *", + "staff_work_original_title": "Original title", + "staff_work_main_author": "Main author", + "staff_work_created_from": "Created from (year)", + "staff_work_created_to": "Created until (year)", + "staff_work_created": "Created", + "staff_work_language": "Language", + "staff_work_editions_count": "Editions: %1$d", + "staff_work_expressions": "Expressions", + "staff_work_books": "Books of this work", + "staff_work_attach": "Link a book", + "staff_work_detach": "Unlink the book", + "staff_work_book_attached": "Book linked to the work", + "staff_work_book_detached": "Book unlinked", + "staff_expression_add": "Add an expression", + "staff_expression_edit": "Edit expression", + "staff_expression_delete": "Delete expression", + "staff_expression_saved": "Expression saved", + "staff_expression_deleted": "Expression deleted", + "staff_expression_type": "Type", + "staff_expression_text": "Text", + "staff_expression_translation": "Translation", + "staff_expression_revision": "Revision", + "staff_expression_adaptation": "Adaptation", + "staff_expression_critical": "Critical edition", + "staff_expression_audio": "Audio", + "staff_expression_other": "Other", + "staff_field_title_plain": "Title" } diff --git a/i18n/fr.json b/i18n/fr.json index ab41aed..e7a4498 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -355,6 +355,7 @@ "reviews_empty": "Aucun avis pour l'instant. Soyez le premier !", "reviews_rating_content_desc": "Note : %1$s sur 5", "reviews_write": "Rédiger un avis", + "reviews_borrow_first": "Vous pourrez donner votre avis sur ce livre après l'avoir emprunté.", "reviews_others_title": "L'avis des autres lecteurs", "reviews_your_review": "Votre avis", "reviews_edit": "Modifier", @@ -627,5 +628,819 @@ "credit_illustrator": "Illustrateur", "credit_editor": "Éditeur scientifique", "credit_colorist": "Coloriste", - "credit_contributor": "Contributeur" + "credit_contributor": "Contributeur", + "offline_results": "Vous êtes hors ligne : ces résultats proviennent de la copie enregistrée sur l’appareil.", + "offline_lists_cached": "Vous êtes hors ligne : voici la dernière liste enregistrée sur l’appareil. Les actions nécessitent une connexion.", + "offline_actions_unavailable": "Copie hors ligne. Les prêts, réservations, favoris et avis nécessitent une connexion.", + "offline_availability_last_known": "Dernière information du %1$s, exemplaires en rayon : %2$d sur %3$d. Vérifiez en ligne avant de venir.", + "offline_availability_unknown": "La disponibilité ne peut pas être vérifiée hors ligne.", + "profile_action_staff": "Espace admin", + "profile_action_calendar": "Prêts dans votre agenda", + "calendar_subscribe_title": "Prêts dans votre agenda", + "calendar_subscribe_body": "Un lien privé avec vos prêts, du début à l’échéance, les livres prêts à retirer, les prêts programmés et vos réservations. Les applications d’agenda le mettent à jour d’elles-mêmes.", + "calendar_status_disabled": "Le lien d’agenda est désactivé.", + "calendar_enable": "Créer le lien d’agenda", + "calendar_created_on": "Créé le %1$s", + "calendar_copy_link": "Copier le lien", + "calendar_link_copied": "Lien copié", + "calendar_open_app": "Ouvrir dans une application d’agenda", + "calendar_hint_google": "Google Agenda : ouvrez calendar.google.com sur un ordinateur, choisissez Autres agendas, À partir de l’URL, et collez le lien.", + "calendar_regenerate": "Régénérer le lien", + "calendar_regenerate_confirm": "Le lien actuel cesse immédiatement de fonctionner : les agendas abonnés ne se mettent plus à jour tant que vous n’ajoutez pas le nouveau.", + "calendar_disable": "Désactiver le lien", + "calendar_disable_confirm": "Les agendas abonnés à ce lien ne recevront plus vos prêts.", + "calendar_disabled": "Lien d’agenda désactivé", + "calendar_link_ready": "Nouveau lien d’agenda prêt", + "calendar_error": "Impossible de charger le lien d’agenda.", + "staff_title": "Espace admin", + "staff_section_add": "Ajouter au catalogue", + "staff_section_manage": "Gérer", + "staff_scan": "Scanner un ISBN", + "staff_scan_subtitle": "Lecteur de codes-barres avec mode en série", + "staff_add_manual": "Ajouter manuellement", + "staff_add_manual_subtitle": "Pour les documents sans ISBN : partitions, brochures, objets", + "staff_books": "Livres", + "staff_authors": "Auteurs", + "staff_publishers": "Éditeurs", + "staff_genres": "Genres", + "staff_shelves": "Étagères", + "staff_forbidden": "Cette action est réservée au personnel de la bibliothèque.", + "staff_error_load": "Impossible de charger les données.", + "staff_error_save": "Échec de l’enregistrement. Veuillez réessayer.", + "staff_error_busy": "Le catalogue est occupé par un autre enregistrement. Réessayez dans un instant.", + "staff_error_offline": "Pas de connexion. Les modifications du catalogue nécessitent le serveur.", + "staff_cd_decrease": "Diminuer", + "staff_cd_increase": "Augmenter", + "staff_option_none": "Aucun", + "staff_entity_new": "Ajouter : %1$s", + "staff_entity_new_chip": "%1$s (nouveau)", + "staff_entity_use_existing": "Utiliser l’existant", + "staff_picker_empty": "Aucun résultat.", + "staff_books_search": "Rechercher un titre, un auteur ou un ISBN", + "staff_books_empty": "Aucune notice trouvée", + "staff_copies_count": "Disponibles : %1$d sur %2$d", + "staff_copies_total": "Exemplaires au catalogue : %1$d", + "staff_book_new": "Nouvelle notice", + "staff_book_edit": "Modifier la notice", + "staff_book_save": "Enregistrer", + "staff_book_saved": "Notice enregistrée", + "staff_book_delete": "Supprimer la notice", + "staff_book_delete_confirm": "Supprimer « %1$s » ? La notice disparaît du catalogue et ses identifiants redeviennent libres.", + "staff_book_deleted": "Notice supprimée", + "staff_book_delete_active_circulation": "Ce titre a des prêts ou des réservations en cours et ne peut pas être supprimé.", + "staff_book_duplicate_title": "Déjà au catalogue", + "staff_book_duplicate_body": "Une notice avec cet identifiant existe déjà :", + "staff_change_identifier": "Modifier l’identifiant", + "staff_form_fix_errors": "Vérifiez les champs signalés.", + "staff_field_required": "Obligatoire", + "staff_field_invalid": "Non valide", + "staff_identifier_invalid": "ISBN ou EAN non valide", + "staff_lookup_failed_manual": "La recherche en ligne n’a pas répondu : complétez la notice manuellement.", + "staff_section_cover": "Couverture", + "staff_section_identity": "Titre et identifiants", + "staff_section_people": "Auteurs et éditeurs", + "staff_section_classification": "Classification", + "staff_section_content": "Contenu et édition", + "staff_section_holdings": "Exemplaires et emplacement", + "staff_section_acquisition": "Acquisition et notes", + "staff_field_title": "Titre *", + "staff_field_subtitle": "Sous-titre", + "staff_field_isbn13": "ISBN-13", + "staff_field_isbn10": "ISBN-10", + "staff_field_ean": "EAN", + "staff_field_issn": "ISSN", + "staff_add_author": "Ajouter un auteur", + "staff_add_publisher": "Ajouter un éditeur", + "staff_field_genre": "Genre", + "staff_field_subgenre": "Sous-genre", + "staff_field_media_type": "Type de support", + "staff_field_language": "Langue", + "staff_field_format": "Format", + "staff_field_dewey": "Classification Dewey", + "staff_field_description": "Description", + "staff_field_keywords": "Mots-clés", + "staff_field_year": "Année", + "staff_field_pages": "Pages", + "staff_field_pub_date": "Date de publication", + "staff_field_edition": "Édition", + "staff_field_place": "Lieu de publication", + "staff_field_series": "Collection", + "staff_field_series_number": "Numéro", + "staff_copies": "Exemplaires", + "staff_copies_to_add": "Exemplaires à ajouter", + "staff_add_copies": "Ajouter des exemplaires", + "staff_copies_added": "Exemplaires ajoutés", + "staff_shelf": "Étagère", + "staff_level": "Tablette", + "staff_level_number": "Tablette %1$d", + "staff_position_auto": "La position est attribuée automatiquement (prochaine place libre).", + "staff_position_auto_label": "Prochaine position libre : %1$s", + "staff_field_acquisition_date": "Date d’acquisition", + "staff_field_acquisition_type": "Type d’acquisition", + "staff_field_price": "Prix", + "staff_field_inventory_base": "Numéro d’inventaire", + "staff_field_notes": "Notes", + "staff_cover_from_url": "Depuis une URL", + "staff_cover_take_photo": "Prendre une photo", + "staff_cover_pick": "Choisir une image", + "staff_cover_url_label": "Adresse de l’image", + "staff_cover_pending": "Nouvelle couverture, appliquée à l’enregistrement.", + "staff_cover_discard": "Annuler", + "staff_cover_failed": "Enregistré, mais la couverture n’a pas pu être envoyée. Enregistrez à nouveau pour réessayer.", + "staff_camera_unavailable": "L’appareil photo n’est pas disponible sur cet appareil.", + "staff_records_empty": "Rien pour l’instant", + "staff_record_new": "Nouveau", + "staff_record_edit": "Modifier", + "staff_record_saved": "Enregistré", + "staff_record_duplicate_title": "Existe déjà", + "staff_record_duplicate_body": "« %1$s » est déjà au catalogue. Modifier plutôt la notice existante ?", + "staff_field_name": "Nom *", + "staff_field_pseudonym": "Pseudonyme", + "staff_field_born": "Naissance", + "staff_field_died": "Décès", + "staff_field_nationality": "Nationalité", + "staff_field_website": "Site web", + "staff_field_gnd": "Identifiant GND", + "staff_field_biography": "Biographie", + "staff_field_address": "Adresse", + "staff_field_phone": "Téléphone", + "staff_field_email": "E-mail", + "staff_genre_add": "Ajouter un genre", + "staff_subgenre_add_to": "Ajouter un sous-genre à %1$s", + "staff_genre_exists": "Ce genre existe déjà.", + "staff_shelf_add": "Ajouter une étagère", + "staff_level_add": "Ajouter une tablette", + "staff_level_add_to": "Ajouter une tablette à %1$s", + "staff_shelf_exists": "Une étagère avec ce code existe déjà.", + "staff_level_exists": "Cette étagère a déjà une tablette avec ce numéro.", + "staff_shelf_no_levels": "Aucune tablette", + "staff_field_shelf_code": "Code *", + "staff_field_level_number": "Numéro de tablette *", + "staff_scan_hint": "Placez le code-barres dans le cadre. Chaque code est lu deux fois avant d’être accepté.", + "staff_scan_manual_label": "Saisir un ISBN ou un EAN", + "staff_scan_lookup": "Rechercher", + "staff_scan_looking": "Recherche en cours…", + "staff_scan_permission_rationale": "L’appareil photo lit les codes-barres des livres. Vous pouvez aussi saisir l’ISBN ci-dessous.", + "staff_scan_permission_allow": "Autoriser l’appareil photo", + "staff_scan_permission_denied": "L’accès à l’appareil photo est désactivé pour Pinakes. Activez-le dans les paramètres ou saisissez l’ISBN ci-dessous.", + "staff_scan_permission_settings": "Ouvrir les paramètres", + "staff_scan_torch_on": "Allumer la lampe", + "staff_scan_torch_off": "Éteindre la lampe", + "staff_scan_settings": "Réglages de la série", + "staff_scan_settings_hint": "Appliqués à chaque titre confirmé pendant cette session.", + "staff_scan_batch_title": "Mode en série", + "staff_scan_batch_copies": "Exemplaires : %1$d", + "staff_scan_no_location": "Aucun emplacement", + "staff_scan_auto_on": "confirmation automatique activée", + "staff_scan_auto_off": "confirmer chaque titre", + "staff_scan_auto_confirm": "Confirmation automatique", + "staff_scan_auto_confirm_hint": "Ajoute les titres trouvés sans l’aperçu. Les doublons et les codes inconnus demandent toujours.", + "staff_scan_session_count": "Ajoutés : %1$d", + "staff_scan_added": "Ajouté au catalogue", + "staff_scan_added_list": "Ajoutés pendant cette session", + "staff_scan_added_new": "Nouvelle notice · exemplaires : %1$d", + "staff_scan_added_copies": "Exemplaires ajoutés : %1$d", + "staff_scan_confirm": "Ajouter au catalogue", + "staff_scan_edit_details": "Modifier d’abord les détails", + "staff_scan_skip": "Passer", + "staff_scan_duplicate": "Déjà au catalogue", + "staff_scan_open_existing": "Ouvrir la notice existante", + "staff_scan_add_copy": "Ajouter des exemplaires", + "staff_scan_not_found": "Introuvable en ligne", + "staff_scan_not_found_body": "Aucune source ne connaît %1$s. Vous pouvez créer la notice manuellement.", + "staff_scan_not_found_ean": "%1$s n’est pas un ISBN de livre et aucune source ne le connaît. Vous pouvez créer la notice manuellement.", + "staff_scan_not_found_manual": "Aucune source ne connaît cet identifiant : complétez la notice manuellement.", + "staff_scan_create_manual": "Créer manuellement", + "staff_scan_rescan": "Scanner à nouveau", + "staff_scrape_unavailable": "Les services de recherche sont injoignables pour le moment.", + "staff_scan_offline_title": "Le scan nécessite une connexion", + "staff_scan_offline_subtitle": "Chaque code est recherché sur le serveur de la bibliothèque. Reconnectez-vous et réessayez.", + "staff_level_required": "Choisissez une tablette pour cette étagère, ou réglez l’étagère sur aucune.", + "offline_generic": "Vous êtes hors ligne. Vérifiez votre connexion et réessayez.", + "offline_no_copy_title": "Vous êtes hors ligne", + "offline_no_copy": "Aucune copie n’est encore enregistrée sur l’appareil. Ouvrez cette page une fois connecté pour la garder hors ligne.", + "offline_availability_hidden": "Vous êtes hors ligne : la disponibilité et les actions de prêt sont masquées jusqu’au retour de la connexion.", + "staff_search_authors": "Rechercher des auteurs", + "staff_search_publishers": "Rechercher des éditeurs", + "staff_search_genres": "Rechercher des genres", + "staff_record_author": "Auteur", + "staff_record_publisher": "Éditeur", + "staff_record_books_count": "Livres au catalogue", + "staff_record_life": "Vie", + "staff_record_created": "Créé", + "staff_record_updated": "Mis à jour", + "staff_record_no_books": "Aucun livre au catalogue pour l’instant", + "staff_section_circulation": "Circulation", + "staff_section_books": "Outils pour les livres", + "staff_section_plugins": "Contenus des extensions", + "staff_circulation_desk": "Banque de prêt", + "staff_circulation_desk_subtitle": "Demandes, retraits, prêts et retards", + "staff_scan_copy": "Scanner un exemplaire", + "staff_scan_copy_subtitle": "Rendre, prêter ou retrouver un exemplaire par son étiquette", + "staff_loan_new": "Nouveau prêt", + "staff_reservations": "Réservations", + "staff_offline_circulation_hidden": "La banque de prêt a besoin d’une connexion : elle revient dès que vous êtes en ligne.", + "staff_series": "Collections", + "staff_merges": "Fusionner les doublons", + "staff_merges_subtitle": "Auteurs, éditeurs et genres", + "staff_imports": "Importer un fichier", + "staff_imports_subtitle": "CSV ou LibraryThing, avec une progression reprenable", + "staff_covers": "Couvertures et enrichissement", + "staff_covers_subtitle": "Trouver les couvertures manquantes, compléter les données", + "staff_labels_settings": "Réglages des étiquettes", + "staff_labels_settings_subtitle": "Taille et contenu des étiquettes imprimées", + "staff_desiderata": "Propositions de don", + "staff_desiderata_subtitle": "Accepter, refuser et recevoir les livres proposés", + "staff_archives": "Archives", + "staff_archives_subtitle": "Unités d’archives, pièces jointes et publication", + "staff_authorities": "Notices d’autorité", + "staff_circulation_actions": "Actions rapides", + "staff_circulation_today": "À rendre aujourd’hui : %1$d · Rendus aujourd’hui : %2$d", + "staff_loans_title": "Prêts", + "staff_loans_pending": "Demandes", + "staff_loans_pickup": "À retirer", + "staff_loans_scheduled": "Programmés", + "staff_loans_active": "En cours", + "staff_loans_overdue": "En retard", + "staff_loans_history": "Historique", + "staff_loans_all": "Tous les prêts", + "staff_loans_search": "Titre, lecteur, carte ou code d’exemplaire", + "staff_loans_empty": "Aucun prêt ici", + "staff_select_many": "Sélectionner plusieurs", + "staff_select_done": "Terminer la sélection", + "staff_selected": "Sélectionné", + "staff_not_selected": "Non sélectionné", + "staff_selected_count": "Sélectionnés : %1$d", + "staff_bulk_extend": "Prolonger", + "staff_bulk_extend_body": "Prêts sélectionnés : %1$d. Repousser leur échéance de :", + "staff_bulk_extend_days": "Jours", + "staff_bulk_extend_days_invalid": "Entre 1 et 365 jours", + "staff_bulk_extended": "Prêts prolongés : %1$d · jours ajoutés : %2$d", + "staff_bulk_recalled": "Relances envoyées : %1$d, ignorées : %2$d", + "staff_loan_title": "Prêt", + "staff_loan_borrower": "Lecteur", + "staff_loan_details": "Détails du prêt", + "staff_user_card": "Carte", + "staff_user_open": "Ouvrir le lecteur", + "staff_open_book": "Ouvrir le livre", + "staff_copy_inventory": "Exemplaire", + "staff_loan_requested": "Demandé le", + "staff_loan_loaned": "Début du prêt", + "staff_loan_due": "Échéance", + "staff_loan_pickup_deadline": "À retirer avant", + "staff_loan_returned_at": "Rendu le", + "staff_loan_renewals": "Renouvellements", + "staff_loan_renewals_value": "%1$d sur %2$d", + "staff_loan_recalls": "Relances", + "staff_loan_processed_by": "Traité par", + "staff_loan_days_overdue": "Jours de retard : %1$d", + "staff_loan_pickup_by": "À retirer avant le %1$s", + "staff_loan_starts_on": "Commence le %1$s", + "staff_penalty": "Pénalité", + "staff_penalty_invalid": "Un montant comme 12,50 (exemplaires perdus ou abîmés seulement)", + "staff_loan_action_approve": "Approuver", + "staff_loan_action_reject": "Refuser", + "staff_loan_action_confirm_pickup": "Confirmer le retrait", + "staff_loan_action_cancel_pickup": "Annuler le retrait", + "staff_loan_action_return": "Retour", + "staff_loan_action_renew": "Renouveler", + "staff_loan_action_edit": "Modifier le prêt", + "staff_loan_action_recall": "Envoyer une relance", + "staff_loan_action_receipt": "Reçu PDF", + "staff_loan_action_email_receipt": "Envoyer le reçu par email", + "staff_loan_approve_body": "Approuver cette demande ? Le lecteur est prévenu.", + "staff_loan_pickup_body": "Le lecteur est au guichet et emporte l’exemplaire maintenant ?", + "staff_loan_renew_body": "Prolonger ce prêt d’une période ?", + "staff_loan_reject_body": "Le lecteur est prévenu, avec le motif si vous en donnez un.", + "staff_loan_cancel_pickup_body": "L’exemplaire redevient disponible pour le lecteur suivant.", + "staff_reason_optional": "Motif (facultatif)", + "staff_loan_approved": "Demande approuvée", + "staff_loan_rejected": "Demande refusée", + "staff_loan_picked_up": "Retrait confirmé : le prêt est en cours", + "staff_loan_pickup_cancelled": "Retrait annulé", + "staff_loan_renewed": "Prêt renouvelé", + "staff_loan_returned": "Retour enregistré", + "staff_loan_updated": "Prêt mis à jour", + "staff_recall_sent": "Relance envoyée", + "staff_receipt_emailed": "Reçu envoyé par email", + "staff_mail_not_sent": "L’email n’a pas pu être envoyé. Vérifiez les réglages de messagerie de la bibliothèque.", + "staff_receipt_ready": "Reçu du prêt", + "staff_download_failed": "Le fichier n’a pas pu être téléchargé.", + "staff_open_file": "Ouvrir", + "staff_share_file": "Partager", + "staff_return_outcome": "L’exemplaire revient", + "staff_return_outcome_returned": "En rayon", + "staff_return_outcome_maintenance": "En maintenance", + "staff_return_outcome_restoration": "En restauration", + "staff_return_outcome_lost": "Perdu", + "staff_return_outcome_damaged": "Abîmé", + "staff_queue_position": "File : n° %1$d", + "staff_reservation_expires": "Expire le %1$s", + "staff_pick_user": "Lecteur", + "staff_search_users": "Nom, email, carte ou téléphone", + "staff_search_min_chars": "Tapez au moins 2 caractères.", + "staff_picker_confirm": "Ajouter (%1$d)", + "staff_date_pick": "Choisir une date", + "staff_err_not_eligible": "Ce lecteur ne peut pas emprunter pour l’instant (suspendu, carte expirée ou non autorisé).", + "staff_err_not_available": "Aucun exemplaire n’est disponible à ces dates.", + "staff_err_max_loans": "Ce lecteur a déjà le nombre maximal de prêts.", + "staff_err_loan_closed": "Ce prêt est clos et ne peut plus être modifié.", + "staff_err_concurrent_retry": "Quelqu’un d’autre a modifié ce prêt au même moment. Réessayez.", + "staff_err_not_returnable": "Ce prêt ne peut pas être rendu dans son état actuel.", + "staff_err_max_renewals": "Le prêt a déjà été renouvelé le nombre maximal de fois.", + "staff_err_loan_overdue": "Un prêt en retard ne peut pas être renouvelé : enregistrez d’abord le retour.", + "staff_err_loan_not_active": "Seul un prêt en cours peut être renouvelé.", + "staff_err_past_date": "Les dates sont dans le passé.", + "staff_err_ambiguous": "Plusieurs prêts ouverts correspondent à ce code : ouvrez le prêt depuis la liste.", + "staff_err_copy_on_loan": "Cet exemplaire est prêté : enregistrez d’abord le retour.", + "staff_err_copy_held": "Cet exemplaire est réservé pour un lecteur.", + "staff_err_copy_in_circulation": "Cet exemplaire est en circulation et ne peut pas être supprimé.", + "staff_err_copy_has_history": "Cet exemplaire a un historique de prêts : marquez-le perdu ou retiré plutôt que de le supprimer.", + "staff_err_no_copies": "Ce livre n’a aucun exemplaire pour lequel imprimer des étiquettes.", + "staff_err_offer_closed": "Cette proposition a déjà été traitée.", + "staff_err_out_of_order": "L’import a avancé ailleurs : reprise là où en est le serveur.", + "staff_err_job_busy": "Cet import est en cours sur un autre appareil. Réessayez dans un instant.", + "staff_err_gone": "Cet import n’est plus disponible (terminé, annulé ou expiré).", + "staff_err_upload_rejected": "Le serveur a refusé le fichier (type ou taille).", + "staff_err_feature_disabled": "Cette section est désactivée sur le serveur de la bibliothèque.", + "staff_err_rate_limited": "Trop de requêtes : attendez une minute et réessayez.", + "staff_labels_hint": "Dimensions en millimètres. Elles valent pour toutes les étiquettes imprimées par la bibliothèque, sur le web aussi.", + "staff_labels_width": "Largeur (mm)", + "staff_labels_height": "Hauteur (mm)", + "staff_labels_padding": "Marge intérieure (mm)", + "staff_labels_range": "Entre 10 et 100 mm (marge : de 0 à 20)", + "staff_labels_show_app_name": "Nom de la bibliothèque", + "staff_labels_show_title": "Titre", + "staff_labels_show_subtitle": "Sous-titre", + "staff_labels_show_author_publisher": "Auteur et éditeur", + "staff_labels_show_dewey": "Classification Dewey", + "staff_copy_code": "Code d’exemplaire", + "staff_copy_code_hint": "Facultatif : prêter cet exemplaire précis. Vide : le serveur en choisit un libre.", + "staff_copy_code_required": "Saisissez un code d’exemplaire", + "staff_copy_code_value": "Exemplaire %1$s", + "staff_copy_not_found": "Aucun exemplaire avec ce code", + "staff_copy_not_found_body": "Aucun exemplaire du catalogue ne porte le code %1$s.", + "staff_copy_not_found_isbn": "%1$s ressemble à l’ISBN d’un livre, pas à l’étiquette d’un exemplaire. Scannez l’étiquette d’inventaire de l’exemplaire.", + "staff_copy_returned": "Retour enregistré : %1$s", + "staff_copy_returned_count": "Rendus : %1$d", + "staff_scan_copy_hint": "Placez l’étiquette d’inventaire de l’exemplaire dans le cadre, ou saisissez son code ci-dessous.", + "staff_scan_copy_permission": "L’appareil photo lit les étiquettes des exemplaires. Vous pouvez aussi saisir le code ci-dessous.", + "staff_scan_next": "Scanner l’exemplaire suivant", + "staff_loan_new_with_copy": "Nouveau prêt avec cet exemplaire", + "staff_loan_open": "Ouvrir le prêt", + "staff_hand_over_now": "Remettre maintenant", + "staff_hand_over_now_hint": "Le lecteur emporte l’exemplaire au guichet : le prêt démarre aussitôt.", + "staff_hand_over_future": "Un prêt qui commence plus tard est programmé et remis ce jour-là.", + "staff_loan_create": "Créer le prêt", + "staff_loan_dates": "Dates", + "staff_loan_dates_hint": "Début vide : aujourd’hui. Échéance vide : la durée de prêt standard de la bibliothèque.", + "staff_loan_dates_order": "La date de fin est antérieure à la date de début.", + "staff_loan_summary_start": "Début : %1$s", + "staff_pick_book": "Livre", + "staff_pick_book_placeholder": "Rechercher dans le catalogue", + "staff_pick_user_placeholder": "Rechercher un lecteur", + "staff_user_not_eligible": "Ne peut pas emprunter maintenant : %1$s", + "staff_user_open_loans": "Prêts ouverts", + "staff_user_status": "Statut", + "staff_user_status_active": "Actif", + "staff_user_status_suspended": "Suspendu", + "staff_user_status_expired": "Carte expirée", + "staff_user_card_expires": "Carte valable jusqu’au", + "staff_reservation_new": "Nouvelle réservation", + "staff_reservation_create": "Créer la réservation", + "staff_reservation_edit": "Modifier les dates", + "staff_reservation_cancel": "Annuler la réservation", + "staff_reservation_cancel_body": "Annuler la réservation de %1$s ? Le suivant dans la file avance.", + "staff_reservation_reactivate": "Réactiver", + "staff_reservation_cancelled": "Réservation annulée", + "staff_reservation_reactivated": "Réservation réactivée", + "staff_reservation_updated": "Réservation mise à jour", + "staff_reservation_from": "Souhaité à partir du", + "staff_reservation_to": "Souhaité jusqu’au", + "staff_reservation_expiry": "Expiration de la réservation", + "staff_reservation_dates_hint": "Laissez les dates vides pour attendre le prochain exemplaire libre.", + "staff_reservations_active": "Actives", + "staff_reservations_completed": "Terminées", + "staff_reservations_cancelled": "Annulées", + "staff_reservations_all": "Toutes", + "staff_reservations_empty": "Aucune réservation", + "staff_reservations_search": "Titre ou lecteur", + "staff_return_title": "Enregistrer le retour", + "staff_err_idempotency_conflict": "Le serveur a déjà reçu cette demande avec d’autres données. Vérifiez le formulaire et renvoyez-le.", + "staff_copy_no_code": "Exemplaire sans code", + "staff_copy_edit": "Modifier l’exemplaire", + "staff_copy_add_with_code": "Ajouter un exemplaire avec son code", + "staff_copy_code_new_hint": "Laissez vide pour que la bibliothèque le numérote.", + "staff_copy_status": "État", + "staff_copy_added": "Exemplaire ajouté", + "staff_copy_saved": "Exemplaire enregistré", + "staff_copy_deleted": "Exemplaire supprimé", + "staff_copy_delete": "Supprimer l’exemplaire", + "staff_copy_delete_body": "Supprimer l’exemplaire %1$s ? Seul un exemplaire jamais prêté peut être supprimé.", + "staff_copy_code_taken": "Le code %1$s est déjà utilisé par un autre exemplaire.", + "staff_labels_book_pdf": "Étiquette du livre", + "staff_labels_copies_pdf": "Étiquettes des exemplaires", + "staff_labels_this_copy": "Étiquette de cet exemplaire", + "staff_labels_ready": "Étiquettes", + "staff_series_new": "Nouvelle collection", + "staff_series_search": "Rechercher une collection", + "staff_series_empty": "Aucune collection pour l’instant", + "staff_series_book_count": "Livres : %1$d", + "staff_series_complete": "Complète", + "staff_series_exists": "Une collection « %1$s » existe déjà. L’ouvrir ?", + "staff_series_open_existing": "L’ouvrir", + "staff_series_type": "Type", + "staff_series_parent": "Fait partie de", + "staff_series_parent_hint": "Nom de la collection plus large (créée si elle manque).", + "staff_series_group": "Groupe", + "staff_series_cycle": "Cycle", + "staff_series_cycle_order": "Ordre", + "staff_series_rename_hint": "Pour changer le nom, utilisez Renommer.", + "staff_series_saved": "Collection enregistrée", + "staff_series_edit": "Modifier les détails", + "staff_series_rename": "Renommer", + "staff_series_renamed": "Collection renommée", + "staff_series_rename_clash": "Une collection « %1$s » existe déjà : fusionner les deux ?", + "staff_series_merge": "Fusionner avec une autre collection", + "staff_series_merge_body": "Ses livres passent dans la collection choisie, puis celle-ci est supprimée.", + "staff_series_merge_into": "Fusionner avec %1$s", + "staff_series_merged": "Collections fusionnées", + "staff_series_merged_count": "Collections fusionnées. Livres déplacés : %1$d", + "staff_series_delete": "Supprimer la collection", + "staff_series_delete_body": "Supprimer « %1$s » ? Ses livres (%2$d) restent au catalogue sans collection.", + "staff_series_parent_work": "Créer l’œuvre mère", + "staff_series_parent_work_body": "Une notice qui regroupe toute la collection, comme sur le web.", + "staff_series_parent_work_create": "Créer", + "staff_series_parent_work_created": "Œuvre mère créée", + "staff_series_add_books": "Ajouter des livres", + "staff_series_books_added": "Livres ajoutés : %1$d", + "staff_series_books": "Livres (%1$d)", + "staff_series_children": "Collections internes", + "staff_series_numbers": "Numéros", + "staff_series_number_value": "N° %1$s", + "staff_series_no_number": "Sans numéro", + "staff_series_number_hint": "Vide efface le numéro.", + "staff_series_number_saved": "Numéro enregistré", + "staff_series_remove_book": "Retirer de la collection", + "staff_series_book_removed": "Livre retiré de la collection", + "staff_yes": "Oui", + "staff_more_actions": "Autres actions", + "staff_expand": "Déplier", + "staff_collapse": "Replier", + "staff_merge_pick": "Choisir les notices", + "staff_merge_manual_hint": "Choisissez deux notices ou plus qui sont la même, puis laquelle reste.", + "staff_merge_review": "Vérifier la fusion", + "staff_merge_suggested": "Doublons possibles", + "staff_merge_no_duplicates": "Aucun auteur en double trouvé.", + "staff_merge_author_books": "%1$s · livres : %2$d", + "staff_merge_primary_hint": "La notice choisie reste ; les autres y sont fusionnées et leurs livres déplacés.", + "staff_merge_new_name": "Nouveau nom (facultatif)", + "staff_merge_new_name_hint": "Vide garde le nom de la notice qui reste.", + "staff_merge_action": "Fusionner", + "staff_merge_done": "Notices fusionnées : %1$d", + "staff_merge_genre_hint": "Les livres et sous-genres du premier genre passent au second, puis le premier est supprimé.", + "staff_merge_genre_source": "Genre à supprimer", + "staff_merge_genre_target": "Genre conservé", + "staff_merge_genre_pick": "Choisir un genre", + "staff_merge_genre_done": "Genres fusionnés. Livres déplacés : %1$d · sous-genres déplacés : %2$d", + "staff_merge_genre_descendant": "Un genre ne peut pas être fusionné avec l’un de ses sous-genres.", + "staff_import_field_authors": "Auteurs", + "staff_import_field_contributors": "Autres contributeurs", + "staff_import_field_publisher": "Éditeur", + "staff_import_field_genre": "Genre", + "staff_import_field_keywords": "Mots-clés", + "staff_import_field_description": "Description", + "staff_import_field_isbn": "ISBN", + "staff_import_field_ean": "EAN", + "staff_import_field_title": "Titre", + "staff_import_field_subtitle": "Sous-titre", + "staff_import_field_year": "Année", + "staff_import_field_language": "Langue", + "staff_import_field_edition": "Édition", + "staff_import_field_pages": "Pages", + "staff_import_field_format": "Format", + "staff_import_field_price": "Prix", + "staff_import_field_series": "Collection", + "staff_import_field_dewey": "Dewey", + "staff_import_kind_csv": "CSV", + "staff_import_kind_lt": "LibraryThing", + "staff_import_file": "Fichier", + "staff_import_choose_file": "Choisir un fichier", + "staff_import_scraping": "Chercher en ligne les données manquantes", + "staff_import_scraping_hint": "Plus lent : une ligne à la fois, et chaque ligne peut prendre du temps.", + "staff_import_update_fields": "Champs à mettre à jour sur les livres déjà au catalogue", + "staff_import_start": "Envoyer et importer", + "staff_import_example": "CSV d’exemple", + "staff_import_open_jobs": "Imports à terminer", + "staff_import_history": "Imports précédents", + "staff_import_progress": "Lignes : %1$d sur %2$d", + "staff_import_when": "Démarré", + "staff_import_by": "Par", + "staff_import_result": "Résultat", + "staff_import_counts": "%1$d nouveaux, %2$d mis à jour, %3$d en échec", + "staff_import_errors_csv": "Télécharger les erreurs", + "staff_import_read_failed": "Le fichier n’a pas pu être lu.", + "staff_import_too_big": "Le fichier dépasse 20 Mo.", + "staff_import_wrong_ext_csv": "Choisissez un fichier .csv.", + "staff_import_wrong_ext_lt": "Choisissez un export LibraryThing (.tsv, .csv ou .txt).", + "staff_import_failed": "L’import n’a pas pu continuer.", + "staff_import_status_running": "Import en cours…", + "staff_import_status_paused": "En pause", + "staff_import_status_done": "Import terminé", + "staff_import_status_failed": "Import échoué", + "staff_import_status_cancelled": "Import annulé", + "staff_import_imported": "Nouveaux livres", + "staff_import_updated": "Livres mis à jour", + "staff_import_scraped": "Complétés en ligne", + "staff_import_authors_created": "Nouveaux auteurs", + "staff_import_publishers_created": "Nouveaux éditeurs", + "staff_import_errors": "Erreurs", + "staff_import_pause": "Mettre en pause", + "staff_import_resume": "Reprendre", + "staff_import_cancel": "Annuler l’import", + "staff_covers_missing_title": "Couvertures manquantes", + "staff_covers_missing_count": "Livres avec ISBN mais sans couverture : %1$d", + "staff_covers_missing_unknown": "Comptage des livres sans couverture…", + "staff_covers_fetch_missing": "Récupérer les couvertures manquantes", + "staff_covers_loop_progress": "%1$d sur %2$d · trouvées %3$d · introuvables %4$d", + "staff_covers_rate_wait": "Petite attente : le serveur limite le nombre de couvertures par minute.", + "staff_covers_stop": "Arrêter", + "staff_covers_server_sync": "Synchroniser toutes les couvertures sur le serveur", + "staff_covers_server_sync_body": "Le serveur récupère toutes les couvertures manquantes d’un coup. Cela peut prendre dix minutes ; gardez l’app ouverte.", + "staff_covers_sync_done": "Couvertures synchronisées : %1$d sur %2$d", + "staff_long_job_running": "Traitement sur le serveur, cela peut prendre quelques minutes…", + "staff_enrich_title": "Enrichissement", + "staff_enrich_with_isbn": "Livres avec ISBN", + "staff_enrich_missing_cover": "Sans couverture", + "staff_enrich_missing_description": "Sans description", + "staff_enrich_pending": "Encore à enrichir", + "staff_enrich_cron": "Enrichir automatiquement", + "staff_enrich_cron_hint": "Le serveur complète un lot de livres selon son planning.", + "staff_enrich_run": "Lancer un lot maintenant", + "staff_enrich_run_body": "Le serveur enrichit les 20 livres suivants. Cela peut prendre quelques minutes.", + "staff_enrich_done": "Livres enrichis : %1$d sur %2$d", + "staff_offers_pending": "À examiner", + "staff_offers_accepted": "Acceptées", + "staff_offers_received": "Reçues", + "staff_offers_rejected": "Refusées", + "staff_offers_all": "Toutes", + "staff_offers_wanted": "Titres recherchés", + "staff_offers_search": "Titre, auteur ou donateur", + "staff_offers_empty": "Aucune proposition ici", + "staff_wanted_empty": "La bibliothèque ne recherche aucun titre", + "staff_offer_from": "de %1$s", + "staff_offer_status_pending": "À examiner", + "staff_offer_status_accepted": "Acceptée", + "staff_offer_status_received": "Reçue", + "staff_offer_status_rejected": "Refusée", + "staff_offer_author": "Auteur", + "staff_offer_publisher": "Éditeur", + "staff_offer_donor": "Donateur", + "staff_offer_for_title": "Pour le titre recherché", + "staff_offer_received_into": "Reçue dans", + "staff_offer_accept": "Accepter", + "staff_offer_reject": "Refuser", + "staff_offer_receive": "Reçue : ajouter l’exemplaire", + "staff_offer_receive_into": "Quelle notice reçoit l’exemplaire ?", + "staff_offer_receive_into_hint": "Cette proposition ne concerne pas un titre recherché : choisissez la notice de l’exemplaire.", + "staff_offer_accepted": "Proposition acceptée", + "staff_offer_rejected": "Proposition refusée", + "staff_offer_received": "Exemplaire ajouté au catalogue", + "staff_offer_delete": "Supprimer la proposition", + "staff_offer_delete_body": "Supprimer la proposition « %1$s » ? C’est irréversible.", + "staff_offer_deleted": "Proposition supprimée", + "staff_wanted_open_offers_count": "Propositions ouvertes : %1$d", + "staff_wanted_mark_received": "Reçu", + "staff_wanted_received": "« %1$s » reçu : exemplaire ajouté", + "staff_wanted_open_offers": "Ce titre a des propositions ouvertes : recevez-le depuis la proposition.", + "staff_archives_search": "Titre ou cote", + "staff_archives_filter_all": "Toutes", + "staff_archives_filter_published": "Publiées", + "staff_archives_filter_draft": "Non publiées", + "staff_archives_empty": "Aucune unité d’archives", + "staff_archive_new": "Nouvelle unité d’archives", + "staff_archive_edit": "Modifier l’unité d’archives", + "staff_archive_published": "Publiée", + "staff_archive_draft": "Non publiée", + "staff_archive_published_hint": "Visible dans les archives publiques et dans l’app.", + "staff_archive_section_identity": "Identification", + "staff_archive_section_dates": "Dates", + "staff_archive_section_description": "Description", + "staff_archive_section_material": "Matériel", + "staff_archive_section_links": "Liens et identifiants", + "staff_archive_section_authorities": "Autorités", + "staff_archive_section_files": "Couverture et documents", + "staff_archive_reference_code": "Cote *", + "staff_archive_institution_code": "Code de l’institution", + "staff_archive_level": "Niveau *", + "staff_archive_constructed_title": "Titre *", + "staff_archive_formal_title": "Titre formel", + "staff_archive_parent_id": "Numéro de l’unité parente", + "staff_archive_date_start": "De (année)", + "staff_archive_date_end": "À (année)", + "staff_archive_extent": "Importance matérielle", + "staff_archive_scope_content": "Portée et contenu", + "staff_archive_language_codes": "Langues (codes)", + "staff_archive_material": "Matériel spécifique", + "staff_archive_color_mode": "Couleur", + "staff_archive_dimensions": "Dimensions", + "staff_archive_photographer": "Photographe", + "staff_archive_publisher": "Éditeur", + "staff_archive_collection_name": "Collection", + "staff_archive_local_classification": "Classification locale", + "staff_archive_iiif": "URL du manifeste IIIF", + "staff_archive_rights": "URL de la déclaration de droits", + "staff_archive_ark": "Identifiant ARK", + "staff_archive_version_note": "Note de version", + "staff_archive_role": "Rôle de la prochaine autorité", + "staff_archive_attach_authority": "Rattacher une autorité", + "staff_archive_attach_role_hint": "Elle sera rattachée comme : %1$s", + "staff_archive_detach": "Détacher %1$s", + "staff_archive_no_cover": "Pas encore de couverture.", + "staff_archive_has_cover": "Une couverture est définie.", + "staff_archive_upload_cover": "Envoyer une couverture", + "staff_archive_remove_cover": "Retirer la couverture", + "staff_archive_upload_document": "Joindre un document", + "staff_archive_remove_document": "Retirer le document", + "staff_archive_save_first": "Enregistrez d’abord l’unité pour rattacher autorités, couverture ou documents.", + "staff_archive_created": "Unité d’archives créée", + "staff_archive_saved": "Unité d’archives enregistrée", + "staff_archive_duplicate": "Cette cote est déjà utilisée dans cette institution.", + "staff_archive_cover_uploaded": "Couverture envoyée", + "staff_archive_cover_removed": "Couverture retirée", + "staff_archive_document_uploaded": "Document joint", + "staff_archive_document_removed": "Document retiré", + "staff_archive_authority_attached": "Autorité rattachée", + "staff_archive_authority_detached": "Autorité détachée", + "staff_archive_delete": "Supprimer l’unité", + "staff_archive_delete_body": "Supprimer « %1$s » et ses pièces jointes ?", + "staff_authorities_search": "Rechercher une autorité", + "staff_authorities_empty": "Aucune notice d’autorité", + "staff_authority_units": "Unités : %1$d", + "staff_authority_new": "Nouvelle autorité", + "staff_authority_edit": "Modifier l’autorité", + "staff_authority_delete": "Supprimer l’autorité", + "staff_authority_delete_body": "Supprimer « %1$s » ? Elle est détachée de toutes les unités.", + "staff_authority_type": "Type *", + "staff_authority_authorised_form": "Forme autorisée du nom *", + "staff_authority_parallel_forms": "Formes parallèles", + "staff_authority_other_forms": "Autres formes", + "staff_authority_identifiers": "Identifiants", + "staff_authority_dates": "Dates d’existence", + "staff_authority_history": "Histoire", + "staff_authority_places": "Lieux", + "staff_authority_legal_status": "Statut juridique", + "staff_authority_functions": "Fonctions et activités", + "staff_authority_mandates": "Textes de référence", + "staff_authority_internal_structure": "Organisation interne", + "staff_authority_general_context": "Contexte général", + "staff_authority_linked_units": "Unités liées", + "staff_export_librarything": "Exporter pour LibraryThing", + "staff_merge_already_done": "Ces notices étaient déjà fusionnées.", + "staff_err_cover_download_failed": "Une couverture a été trouvée mais n’a pas pu être téléchargée.", + "staff_digital_title": "Contenus numériques", + "staff_digital_hint": "Joignez l’eBook ou le livre audio que les lecteurs ouvrent depuis la fiche.", + "staff_digital_ebook": "Envoyer un eBook (PDF ou EPUB)", + "staff_digital_audio": "Envoyer un livre audio", + "staff_digital_uploading": "Envoi…", + "staff_digital_ebook_done": "eBook joint", + "staff_digital_audio_done": "Livre audio joint", + "staff_digital_failed": "Le fichier n’a pas pu être joint.", + "staff_select_all": "Tout sélectionner", + "staff_import_status_gone": "Cet import s’est terminé ailleurs", + "staff_archive_document_too_big": "Le document dépasse 200 Mo.", + "staff_periodicals": "Banque des périodiques", + "staff_periodicals_subtitle": "Recevoir et réclamer des numéros, années, abonnements", + "staff_periodicals_scan_permission": "L’appareil photo lit le code-barres du numéro. Vous pouvez aussi le saisir ci-dessous.", + "staff_periodicals_code": "Code-barres du numéro ou ISSN", + "staff_periodicals_mastheads": "Titres de presse", + "staff_periodicals_search": "Rechercher un titre", + "staff_periodicals_counts": "Années : %1$d · numéros : %2$d", + "staff_periodicals_open": "Ouvrir le titre", + "staff_periodicals_title_only": "Aucun numéro ne porte ce code, mais il appartient à ce titre.", + "staff_periodicals_ambiguous": "Plusieurs numéros portent ce code", + "staff_periodicals_no_match": "Rien ne correspond à ce code", + "staff_periodicals_frequency": "Périodicité", + "staff_periodicals_holdings": "État de collection", + "staff_periodicals_years": "Années", + "staff_periodicals_year_counts": "Possédés : %1$d sur %2$d", + "staff_issue_line": "N° %1$s · %2$d", + "staff_issue_number": "N° %1$s", + "staff_issue_number_label": "Numéro *", + "staff_issue_date": "Date de publication", + "staff_issue_receive": "Reçu", + "staff_issue_claim": "Réclamer", + "staff_issue_add": "Ajouter un numéro", + "staff_issue_added": "Numéro ajouté", + "staff_issue_received": "N° %1$s de %2$s reçu", + "staff_issue_claimed": "N° %1$s de %2$s réclamé", + "staff_issue_status_owned": "Possédé", + "staff_issue_status_missing": "Manquant", + "staff_issue_status_expected": "Attendu", + "staff_issue_status_lost": "Perdu", + "staff_issue_status_claimed": "Réclamé", + "staff_issue_status_discarded": "Écarté", + "staff_year_add": "Ajouter une année", + "staff_year_added": "Année ajoutée", + "staff_year_label": "Année *", + "staff_year_volume": "Volume", + "staff_year_bound": "Reliée en un volume", + "staff_subscriptions": "Abonnements", + "staff_subscription_add": "Ajouter un abonnement", + "staff_subscription_edit": "Modifier l’abonnement", + "staff_subscription_saved": "Abonnement enregistré", + "staff_subscription_until": "Jusqu’au %1$s", + "staff_subscription_inactive": "Inactif", + "staff_subscription_supplier": "Fournisseur *", + "staff_subscription_cost": "Coût", + "staff_subscription_currency": "Devise", + "staff_subscription_start": "Début", + "staff_subscription_end": "Fin", + "staff_subscription_auto_renew": "Renouvellement automatique", + "staff_subscription_active": "Actif", + "staff_clubs": "Clubs de lecture", + "staff_clubs_subtitle": "Réglages, parcours de lecture et membres", + "staff_clubs_empty": "Aucun club de lecture pour l’instant", + "staff_club_new": "Nouveau club", + "staff_club_edit": "Modifier les réglages", + "staff_club_saved": "Club enregistré", + "staff_club_delete": "Supprimer le club", + "staff_club_delete_body": "Supprimer « %1$s » avec ses propositions et discussions ?", + "staff_club_rules": "Règles", + "staff_club_privacy": "Visibilité", + "staff_club_privacy_public": "Public", + "staff_club_privacy_private": "Privé", + "staff_club_privacy_invite": "Sur invitation", + "staff_club_privacy_hidden": "Masqué", + "staff_club_color": "Couleur", + "staff_club_max_members": "Membres maximum", + "staff_club_active": "Actif", + "staff_club_inactive": "Inactif", + "staff_club_moderate": "Modérer les propositions", + "staff_club_members_count": "Membres : %1$d", + "staff_club_workflow": "Parcours de lecture", + "staff_club_workflow_edit": "Modifier le parcours", + "staff_club_workflow_hint": "Au moins deux états. Indiquez où l’on vote, ce qui est lu en ce moment et ce qui est archivé.", + "staff_club_workflow_saved": "Parcours enregistré", + "staff_club_state_label": "État", + "staff_club_state_add": "Ajouter un état", + "staff_club_state_new": "Nouvel état", + "staff_club_flag_voting": "Vote", + "staff_club_flag_current": "En lecture", + "staff_club_flag_archived": "Archivé", + "staff_move_up": "Monter", + "staff_move_down": "Descendre", + "staff_club_members": "Membres", + "staff_club_role": "Rôle", + "staff_club_role_owner": "Propriétaire", + "staff_club_role_moderator": "Modérateur", + "staff_club_role_member": "Membre", + "staff_club_role_guest": "Invité", + "staff_club_member_status": "Adhésion", + "staff_club_member_pending": "En attente", + "staff_club_member_active": "Actif", + "staff_club_member_suspended": "Suspendu", + "staff_club_member_left": "Parti", + "staff_club_member_banned": "Exclu", + "staff_club_member_saved": "Membre mis à jour", + "staff_club_member_add": "Ajouter un membre", + "staff_club_member_add_hint": "Il rejoint comme : %1$s", + "staff_club_member_added": "%1$s ajouté au club", + "staff_works": "Œuvres (FRBR)", + "staff_works_subtitle": "L’œuvre derrière ses éditions et traductions", + "staff_works_search": "Rechercher des œuvres", + "staff_works_empty": "Aucune œuvre pour l’instant", + "staff_work_new": "Nouvelle œuvre", + "staff_work_edit": "Modifier l’œuvre", + "staff_work_saved": "Œuvre enregistrée", + "staff_work_delete": "Supprimer l’œuvre", + "staff_work_delete_body": "Supprimer « %1$s » ? Ses livres restent au catalogue sans œuvre.", + "staff_work_uniform_title": "Titre uniforme *", + "staff_work_original_title": "Titre original", + "staff_work_main_author": "Auteur principal", + "staff_work_created_from": "Créée à partir de (année)", + "staff_work_created_to": "Créée jusqu’à (année)", + "staff_work_created": "Création", + "staff_work_language": "Langue", + "staff_work_editions_count": "Éditions : %1$d", + "staff_work_expressions": "Expressions", + "staff_work_books": "Livres de cette œuvre", + "staff_work_attach": "Lier un livre", + "staff_work_detach": "Délier le livre", + "staff_work_book_attached": "Livre lié à l’œuvre", + "staff_work_book_detached": "Livre délié", + "staff_expression_add": "Ajouter une expression", + "staff_expression_edit": "Modifier l’expression", + "staff_expression_delete": "Supprimer l’expression", + "staff_expression_saved": "Expression enregistrée", + "staff_expression_deleted": "Expression supprimée", + "staff_expression_type": "Type", + "staff_expression_text": "Texte", + "staff_expression_translation": "Traduction", + "staff_expression_revision": "Révision", + "staff_expression_adaptation": "Adaptation", + "staff_expression_critical": "Édition critique", + "staff_expression_audio": "Audio", + "staff_expression_other": "Autre", + "staff_field_title_plain": "Titre" } diff --git a/i18n/it.json b/i18n/it.json index 484a345..8c3191f 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -355,6 +355,7 @@ "reviews_empty": "Ancora nessuna recensione. Sii il primo!", "reviews_rating_content_desc": "Valutazione: %1$s su 5", "reviews_write": "Scrivi una recensione", + "reviews_borrow_first": "Puoi recensire questo libro dopo averlo preso in prestito.", "reviews_others_title": "Recensioni degli altri lettori", "reviews_your_review": "La tua recensione", "reviews_edit": "Modifica", @@ -627,5 +628,819 @@ "credit_illustrator": "Illustratore", "credit_editor": "Curatore", "credit_colorist": "Colorista", - "credit_contributor": "Contributore" + "credit_contributor": "Contributore", + "offline_results": "Sei offline: questi risultati vengono dalla copia salvata sul dispositivo.", + "offline_lists_cached": "Sei offline: questo è l’ultimo elenco salvato sul dispositivo. Le azioni richiedono una connessione.", + "offline_actions_unavailable": "Copia offline. Prestiti, prenotazioni, preferiti e recensioni richiedono una connessione.", + "offline_availability_last_known": "Ultimo dato del %1$s, copie a scaffale: %2$d su %3$d. Verifica online prima di venire.", + "offline_availability_unknown": "La disponibilità non si può verificare offline.", + "profile_action_staff": "Area admin", + "profile_action_calendar": "Prestiti nel calendario", + "calendar_subscribe_title": "Prestiti nel calendario", + "calendar_subscribe_body": "Un link privato con i tuoi prestiti dall'inizio alla scadenza, i libri pronti per il ritiro, i prestiti programmati e le tue prenotazioni. Le app di calendario lo aggiornano da sole.", + "calendar_status_disabled": "Il link del calendario è disattivato.", + "calendar_enable": "Crea link calendario", + "calendar_created_on": "Creato il %1$s", + "calendar_copy_link": "Copia link", + "calendar_link_copied": "Link copiato", + "calendar_open_app": "Apri in un’app di calendario", + "calendar_hint_google": "Google Calendar: apri calendar.google.com da computer, scegli Altri calendari, Da URL e incolla il link.", + "calendar_regenerate": "Rigenera link", + "calendar_regenerate_confirm": "Il link attuale smette subito di funzionare: i calendari iscritti non si aggiornano più finché non aggiungi quello nuovo.", + "calendar_disable": "Disattiva link", + "calendar_disable_confirm": "I calendari iscritti a questo link smettono di ricevere i tuoi prestiti.", + "calendar_disabled": "Link calendario disattivato", + "calendar_link_ready": "Nuovo link calendario pronto", + "calendar_error": "Impossibile caricare il link del calendario.", + "staff_title": "Area admin", + "staff_section_add": "Aggiungi al catalogo", + "staff_section_manage": "Gestisci", + "staff_scan": "Scansiona ISBN", + "staff_scan_subtitle": "Lettore di codici a barre con modalità in serie", + "staff_add_manual": "Aggiungi a mano", + "staff_add_manual_subtitle": "Per materiale senza ISBN: spartiti, opuscoli, oggetti", + "staff_books": "Libri", + "staff_authors": "Autori", + "staff_publishers": "Editori", + "staff_genres": "Generi", + "staff_shelves": "Scaffali", + "staff_forbidden": "Operazione riservata allo staff.", + "staff_error_load": "Impossibile caricare i dati.", + "staff_error_save": "Salvataggio non riuscito. Riprova.", + "staff_error_busy": "Il catalogo è occupato da un altro salvataggio. Riprova tra poco.", + "staff_error_offline": "Nessuna connessione. Le modifiche al catalogo richiedono il server.", + "staff_cd_decrease": "Diminuisci", + "staff_cd_increase": "Aumenta", + "staff_option_none": "Nessuno", + "staff_entity_new": "Aggiungi nuovo: %1$s", + "staff_entity_new_chip": "%1$s (nuovo)", + "staff_entity_use_existing": "Usa esistente", + "staff_picker_empty": "Nessun risultato.", + "staff_books_search": "Cerca titolo, autore o ISBN", + "staff_books_empty": "Nessun record trovato", + "staff_copies_count": "Disponibili: %1$d su %2$d", + "staff_copies_total": "Copie in catalogo: %1$d", + "staff_book_new": "Nuovo record", + "staff_book_edit": "Modifica record", + "staff_book_save": "Salva", + "staff_book_saved": "Record salvato", + "staff_book_delete": "Elimina record", + "staff_book_delete_confirm": "Eliminare «%1$s»? Sparisce dal catalogo e i suoi identificativi tornano liberi.", + "staff_book_deleted": "Record eliminato", + "staff_book_delete_active_circulation": "Questo titolo ha prestiti o prenotazioni attivi e non può essere eliminato.", + "staff_book_duplicate_title": "Già in catalogo", + "staff_book_duplicate_body": "Esiste già un record con questo identificativo:", + "staff_change_identifier": "Cambia identificativo", + "staff_form_fix_errors": "Controlla i campi evidenziati.", + "staff_field_required": "Obbligatorio", + "staff_field_invalid": "Non valido", + "staff_identifier_invalid": "ISBN o EAN non valido", + "staff_lookup_failed_manual": "La ricerca online non ha risposto: compila il record a mano.", + "staff_section_cover": "Copertina", + "staff_section_identity": "Titolo e identificativi", + "staff_section_people": "Autori ed editori", + "staff_section_classification": "Classificazione", + "staff_section_content": "Contenuto ed edizione", + "staff_section_holdings": "Copie e collocazione", + "staff_section_acquisition": "Acquisizione e note", + "staff_field_title": "Titolo *", + "staff_field_subtitle": "Sottotitolo", + "staff_field_isbn13": "ISBN-13", + "staff_field_isbn10": "ISBN-10", + "staff_field_ean": "EAN", + "staff_field_issn": "ISSN", + "staff_add_author": "Aggiungi autore", + "staff_add_publisher": "Aggiungi editore", + "staff_field_genre": "Genere", + "staff_field_subgenre": "Sottogenere", + "staff_field_media_type": "Tipo di supporto", + "staff_field_language": "Lingua", + "staff_field_format": "Formato", + "staff_field_dewey": "Classificazione Dewey", + "staff_field_description": "Descrizione", + "staff_field_keywords": "Parole chiave", + "staff_field_year": "Anno", + "staff_field_pages": "Pagine", + "staff_field_pub_date": "Data di pubblicazione", + "staff_field_edition": "Edizione", + "staff_field_place": "Luogo di pubblicazione", + "staff_field_series": "Collana", + "staff_field_series_number": "Numero", + "staff_copies": "Copie", + "staff_copies_to_add": "Copie da aggiungere", + "staff_add_copies": "Aggiungi copie", + "staff_copies_added": "Copie aggiunte", + "staff_shelf": "Scaffale", + "staff_level": "Mensola", + "staff_level_number": "Mensola %1$d", + "staff_position_auto": "La posizione viene assegnata automaticamente (primo posto libero).", + "staff_position_auto_label": "Prossima posizione libera: %1$s", + "staff_field_acquisition_date": "Data di acquisizione", + "staff_field_acquisition_type": "Tipo di acquisizione", + "staff_field_price": "Prezzo", + "staff_field_inventory_base": "Numero di inventario", + "staff_field_notes": "Note", + "staff_cover_from_url": "Da URL", + "staff_cover_take_photo": "Scatta foto", + "staff_cover_pick": "Scegli immagine", + "staff_cover_url_label": "Indirizzo dell’immagine", + "staff_cover_pending": "Nuova copertina, applicata al salvataggio.", + "staff_cover_discard": "Annulla", + "staff_cover_failed": "Salvato, ma non è stato possibile caricare la copertina. Salva di nuovo per riprovare.", + "staff_camera_unavailable": "La fotocamera non è disponibile su questo dispositivo.", + "staff_records_empty": "Ancora nessun elemento", + "staff_record_new": "Nuovo", + "staff_record_edit": "Modifica", + "staff_record_saved": "Salvato", + "staff_record_duplicate_title": "Esiste già", + "staff_record_duplicate_body": "«%1$s» è già in catalogo. Vuoi modificare il record esistente?", + "staff_field_name": "Nome *", + "staff_field_pseudonym": "Pseudonimo", + "staff_field_born": "Nascita", + "staff_field_died": "Morte", + "staff_field_nationality": "Nazionalità", + "staff_field_website": "Sito web", + "staff_field_gnd": "Identificativo GND", + "staff_field_biography": "Biografia", + "staff_field_address": "Indirizzo", + "staff_field_phone": "Telefono", + "staff_field_email": "Email", + "staff_genre_add": "Aggiungi genere", + "staff_subgenre_add_to": "Aggiungi un sottogenere a %1$s", + "staff_genre_exists": "Questo genere esiste già.", + "staff_shelf_add": "Aggiungi scaffale", + "staff_level_add": "Aggiungi mensola", + "staff_level_add_to": "Aggiungi una mensola a %1$s", + "staff_shelf_exists": "Esiste già uno scaffale con questo codice.", + "staff_level_exists": "Questo scaffale ha già una mensola con quel numero.", + "staff_shelf_no_levels": "Nessuna mensola", + "staff_field_shelf_code": "Codice *", + "staff_field_level_number": "Numero mensola *", + "staff_scan_hint": "Inquadra il codice a barre nella cornice. Ogni codice viene letto due volte prima di essere accettato.", + "staff_scan_manual_label": "Digita un ISBN o EAN", + "staff_scan_lookup": "Cerca", + "staff_scan_looking": "Ricerca in corso…", + "staff_scan_permission_rationale": "La fotocamera legge i codici a barre dei libri. Puoi anche digitare l’ISBN qui sotto.", + "staff_scan_permission_allow": "Consenti fotocamera", + "staff_scan_permission_denied": "L’accesso alla fotocamera è disattivato per Pinakes. Attivalo nelle impostazioni, oppure digita l’ISBN qui sotto.", + "staff_scan_permission_settings": "Apri impostazioni", + "staff_scan_torch_on": "Accendi la luce", + "staff_scan_torch_off": "Spegni la luce", + "staff_scan_settings": "Impostazioni serie", + "staff_scan_settings_hint": "Applicate a ogni titolo che confermi in questa sessione.", + "staff_scan_batch_title": "Modalità in serie", + "staff_scan_batch_copies": "Copie: %1$d", + "staff_scan_no_location": "Nessuna collocazione", + "staff_scan_auto_on": "conferma automatica attiva", + "staff_scan_auto_off": "conferma ogni titolo", + "staff_scan_auto_confirm": "Conferma automatica", + "staff_scan_auto_confirm_hint": "Aggiunge i titoli trovati senza l’anteprima. Duplicati e codici sconosciuti chiedono comunque.", + "staff_scan_session_count": "Aggiunti: %1$d", + "staff_scan_added": "Aggiunto al catalogo", + "staff_scan_added_list": "Aggiunti in questa sessione", + "staff_scan_added_new": "Nuovo record · copie: %1$d", + "staff_scan_added_copies": "Copie aggiunte: %1$d", + "staff_scan_confirm": "Aggiungi al catalogo", + "staff_scan_edit_details": "Modifica prima i dettagli", + "staff_scan_skip": "Salta", + "staff_scan_duplicate": "Già in catalogo", + "staff_scan_open_existing": "Apri esistente", + "staff_scan_add_copy": "Aggiungi copie", + "staff_scan_not_found": "Non trovato online", + "staff_scan_not_found_body": "Nessuna fonte conosce %1$s. Puoi creare il record a mano.", + "staff_scan_not_found_ean": "%1$s non è un ISBN di libro e nessuna fonte lo conosce. Puoi creare il record a mano.", + "staff_scan_not_found_manual": "Nessuna fonte conosce questo identificativo: compila il record a mano.", + "staff_scan_create_manual": "Crea a mano", + "staff_scan_rescan": "Scansiona di nuovo", + "staff_scrape_unavailable": "I servizi di ricerca non sono raggiungibili in questo momento.", + "staff_scan_offline_title": "La scansione richiede una connessione", + "staff_scan_offline_subtitle": "Ogni codice viene cercato sul server della biblioteca. Riconnettiti e riprova.", + "staff_level_required": "Scegli una mensola per questo scaffale, oppure imposta lo scaffale su nessuno.", + "offline_generic": "Sei offline. Controlla la connessione e riprova.", + "offline_no_copy_title": "Sei offline", + "offline_no_copy": "Sul dispositivo non c’è ancora una copia salvata. Apri questa pagina una volta con la connessione per averla anche offline.", + "offline_availability_hidden": "Sei offline: disponibilità e azioni di prestito sono nascoste finché la connessione non torna.", + "staff_search_authors": "Cerca autori", + "staff_search_publishers": "Cerca editori", + "staff_search_genres": "Cerca generi", + "staff_record_author": "Autore", + "staff_record_publisher": "Editore", + "staff_record_books_count": "Libri in catalogo", + "staff_record_life": "Vita", + "staff_record_created": "Creato", + "staff_record_updated": "Aggiornato", + "staff_record_no_books": "Ancora nessun libro in catalogo", + "staff_section_circulation": "Circolazione", + "staff_section_books": "Strumenti per i libri", + "staff_section_plugins": "Contenuti dei plugin", + "staff_circulation_desk": "Banco prestiti", + "staff_circulation_desk_subtitle": "Richieste, ritiri, prestiti e ritardi", + "staff_scan_copy": "Scansiona una copia", + "staff_scan_copy_subtitle": "Restituisci, presta o cerca una copia dalla sua etichetta", + "staff_loan_new": "Nuovo prestito", + "staff_reservations": "Prenotazioni", + "staff_offline_circulation_hidden": "Il banco prestiti richiede una connessione: torna appena sei online.", + "staff_series": "Collane", + "staff_merges": "Unisci duplicati", + "staff_merges_subtitle": "Autori, editori e generi", + "staff_imports": "Importa un file", + "staff_imports_subtitle": "CSV o LibraryThing, con avanzamento ripristinabile", + "staff_covers": "Copertine e arricchimento", + "staff_covers_subtitle": "Trova le copertine mancanti, completa i dati", + "staff_labels_settings": "Impostazioni etichette", + "staff_labels_settings_subtitle": "Dimensioni e contenuto delle etichette stampate", + "staff_desiderata": "Proposte di donazione", + "staff_desiderata_subtitle": "Accetta, rifiuta e ricevi i libri proposti", + "staff_archives": "Archivi", + "staff_archives_subtitle": "Unità archivistiche, allegati e pubblicazione", + "staff_authorities": "Schede di autorità", + "staff_circulation_actions": "Azioni rapide", + "staff_circulation_today": "In scadenza oggi: %1$d · Restituiti oggi: %2$d", + "staff_loans_title": "Prestiti", + "staff_loans_pending": "Richieste", + "staff_loans_pickup": "Da ritirare", + "staff_loans_scheduled": "Programmati", + "staff_loans_active": "In corso", + "staff_loans_overdue": "In ritardo", + "staff_loans_history": "Storico", + "staff_loans_all": "Tutti i prestiti", + "staff_loans_search": "Titolo, utente, tessera o codice copia", + "staff_loans_empty": "Nessun prestito qui", + "staff_select_many": "Seleziona più prestiti", + "staff_select_done": "Termina la selezione", + "staff_selected": "Selezionato", + "staff_not_selected": "Non selezionato", + "staff_selected_count": "Selezionati: %1$d", + "staff_bulk_extend": "Proroga", + "staff_bulk_extend_body": "Prestiti selezionati: %1$d. Sposta in avanti la scadenza di:", + "staff_bulk_extend_days": "Giorni", + "staff_bulk_extend_days_invalid": "Tra 1 e 365 giorni", + "staff_bulk_extended": "Prestiti prorogati: %1$d · giorni aggiunti: %2$d", + "staff_bulk_recalled": "Solleciti inviati: %1$d, saltati: %2$d", + "staff_loan_title": "Prestito", + "staff_loan_borrower": "Utente", + "staff_loan_details": "Dettagli del prestito", + "staff_user_card": "Tessera", + "staff_user_open": "Apri l’utente", + "staff_open_book": "Apri il libro", + "staff_copy_inventory": "Copia", + "staff_loan_requested": "Richiesto il", + "staff_loan_loaned": "Inizio prestito", + "staff_loan_due": "Scadenza", + "staff_loan_pickup_deadline": "Ritiro entro", + "staff_loan_returned_at": "Restituito il", + "staff_loan_renewals": "Rinnovi", + "staff_loan_renewals_value": "%1$d di %2$d", + "staff_loan_recalls": "Solleciti", + "staff_loan_processed_by": "Gestito da", + "staff_loan_days_overdue": "Giorni di ritardo: %1$d", + "staff_loan_pickup_by": "Ritiro entro il %1$s", + "staff_loan_starts_on": "Inizia il %1$s", + "staff_penalty": "Sanzione", + "staff_penalty_invalid": "Un importo come 12,50 (solo copie perse o danneggiate)", + "staff_loan_action_approve": "Approva", + "staff_loan_action_reject": "Rifiuta", + "staff_loan_action_confirm_pickup": "Conferma ritiro", + "staff_loan_action_cancel_pickup": "Annulla ritiro", + "staff_loan_action_return": "Restituisci", + "staff_loan_action_renew": "Rinnova", + "staff_loan_action_edit": "Modifica prestito", + "staff_loan_action_recall": "Invia sollecito", + "staff_loan_action_receipt": "Ricevuta PDF", + "staff_loan_action_email_receipt": "Invia la ricevuta via email", + "staff_loan_approve_body": "Approvare la richiesta? L’utente riceve una notifica.", + "staff_loan_pickup_body": "L’utente è al banco e ritira ora la copia?", + "staff_loan_renew_body": "Prolungare il prestito di un periodo?", + "staff_loan_reject_body": "L’utente riceve una notifica, con il motivo se lo indichi.", + "staff_loan_cancel_pickup_body": "La copia torna disponibile per il prossimo utente.", + "staff_reason_optional": "Motivo (facoltativo)", + "staff_loan_approved": "Richiesta approvata", + "staff_loan_rejected": "Richiesta rifiutata", + "staff_loan_picked_up": "Ritiro confermato: il prestito è in corso", + "staff_loan_pickup_cancelled": "Ritiro annullato", + "staff_loan_renewed": "Prestito rinnovato", + "staff_loan_returned": "Restituzione registrata", + "staff_loan_updated": "Prestito aggiornato", + "staff_recall_sent": "Sollecito inviato", + "staff_receipt_emailed": "Ricevuta inviata via email", + "staff_mail_not_sent": "Non è stato possibile inviare l’email. Controlla le impostazioni di posta della biblioteca.", + "staff_receipt_ready": "Ricevuta del prestito", + "staff_download_failed": "Non è stato possibile scaricare il file.", + "staff_open_file": "Apri", + "staff_share_file": "Condividi", + "staff_return_outcome": "La copia torna", + "staff_return_outcome_returned": "A scaffale", + "staff_return_outcome_maintenance": "In manutenzione", + "staff_return_outcome_restoration": "In restauro", + "staff_return_outcome_lost": "Perso", + "staff_return_outcome_damaged": "Danneggiato", + "staff_queue_position": "In coda: n. %1$d", + "staff_reservation_expires": "Scade il %1$s", + "staff_pick_user": "Utente", + "staff_search_users": "Nome, email, tessera o telefono", + "staff_search_min_chars": "Scrivi almeno 2 caratteri.", + "staff_picker_confirm": "Aggiungi (%1$d)", + "staff_date_pick": "Scegli una data", + "staff_err_not_eligible": "Questo utente ora non può prendere in prestito (sospeso, tessera scaduta o non abilitato).", + "staff_err_not_available": "Nessuna copia disponibile per quelle date.", + "staff_err_max_loans": "Questo utente ha già il numero massimo di prestiti.", + "staff_err_loan_closed": "Questo prestito è chiuso e non si può più modificare.", + "staff_err_concurrent_retry": "Qualcun altro ha modificato il prestito nello stesso momento. Riprova.", + "staff_err_not_returnable": "Questo prestito non può essere restituito nello stato attuale.", + "staff_err_max_renewals": "Il prestito è già stato rinnovato il numero massimo di volte.", + "staff_err_loan_overdue": "Un prestito in ritardo non si può rinnovare: registra prima la restituzione.", + "staff_err_loan_not_active": "Si può rinnovare solo un prestito in corso.", + "staff_err_past_date": "Le date sono nel passato.", + "staff_err_ambiguous": "Più prestiti aperti corrispondono a questo codice: apri il prestito dall’elenco.", + "staff_err_copy_on_loan": "Questa copia è in prestito: registra prima la restituzione.", + "staff_err_copy_held": "Questa copia è riservata a un utente.", + "staff_err_copy_in_circulation": "Questa copia è in circolazione e non si può eliminare.", + "staff_err_copy_has_history": "Questa copia ha uno storico di prestiti: impostala come persa o ritirata invece di eliminarla.", + "staff_err_no_copies": "Questo libro non ha copie di cui stampare le etichette.", + "staff_err_offer_closed": "Questa proposta è già stata gestita.", + "staff_err_out_of_order": "L’importazione è proseguita altrove: riprendo da dove è il server.", + "staff_err_job_busy": "Questa importazione è in corso su un altro dispositivo. Riprova tra poco.", + "staff_err_gone": "Questa importazione non è più disponibile (finita, annullata o scaduta).", + "staff_err_upload_rejected": "Il server ha rifiutato il file (tipo o dimensione).", + "staff_err_feature_disabled": "Quest’area è disattivata sul server della biblioteca.", + "staff_err_rate_limited": "Troppe richieste: attendi un minuto e riprova.", + "staff_labels_hint": "Misure in millimetri. Valgono per tutte le etichette stampate dalla biblioteca, anche dal web.", + "staff_labels_width": "Larghezza (mm)", + "staff_labels_height": "Altezza (mm)", + "staff_labels_padding": "Margine interno (mm)", + "staff_labels_range": "Tra 10 e 100 mm (margine: da 0 a 20)", + "staff_labels_show_app_name": "Nome della biblioteca", + "staff_labels_show_title": "Titolo", + "staff_labels_show_subtitle": "Sottotitolo", + "staff_labels_show_author_publisher": "Autore ed editore", + "staff_labels_show_dewey": "Classificazione Dewey", + "staff_copy_code": "Codice copia", + "staff_copy_code_hint": "Facoltativo: presta proprio questa copia. Vuoto: il server ne sceglie una libera.", + "staff_copy_code_required": "Scrivi un codice copia", + "staff_copy_code_value": "Copia %1$s", + "staff_copy_not_found": "Nessuna copia con questo codice", + "staff_copy_not_found_body": "Nessuna copia del catalogo ha il codice %1$s.", + "staff_copy_not_found_isbn": "%1$s sembra l’ISBN di un libro, non l’etichetta di una copia. Scansiona l’etichetta inventariale della copia.", + "staff_copy_returned": "Restituzione registrata: %1$s", + "staff_copy_returned_count": "Restituiti: %1$d", + "staff_scan_copy_hint": "Inquadra l’etichetta inventariale della copia, oppure scrivi il codice qui sotto.", + "staff_scan_copy_permission": "La fotocamera legge le etichette delle copie. Puoi anche scrivere il codice qui sotto.", + "staff_scan_next": "Scansiona la copia successiva", + "staff_loan_new_with_copy": "Nuovo prestito con questa copia", + "staff_loan_open": "Apri il prestito", + "staff_hand_over_now": "Consegna subito", + "staff_hand_over_now_hint": "L’utente prende la copia al banco: il prestito parte subito.", + "staff_hand_over_future": "Un prestito che inizia più avanti viene programmato e consegnato quel giorno.", + "staff_loan_create": "Crea il prestito", + "staff_loan_dates": "Date", + "staff_loan_dates_hint": "Inizio vuoto: oggi. Scadenza vuota: la durata standard della biblioteca.", + "staff_loan_dates_order": "La data di fine è precedente a quella di inizio.", + "staff_loan_summary_start": "Inizio: %1$s", + "staff_pick_book": "Libro", + "staff_pick_book_placeholder": "Cerca nel catalogo", + "staff_pick_user_placeholder": "Cerca un utente", + "staff_user_not_eligible": "Ora non può prendere in prestito: %1$s", + "staff_user_open_loans": "Prestiti aperti", + "staff_user_status": "Stato", + "staff_user_status_active": "Attivo", + "staff_user_status_suspended": "Sospeso", + "staff_user_status_expired": "Tessera scaduta", + "staff_user_card_expires": "Tessera valida fino al", + "staff_reservation_new": "Nuova prenotazione", + "staff_reservation_create": "Crea la prenotazione", + "staff_reservation_edit": "Modifica le date", + "staff_reservation_cancel": "Annulla prenotazione", + "staff_reservation_cancel_body": "Annullare la prenotazione di %1$s? Il successivo in coda avanza.", + "staff_reservation_reactivate": "Riattiva", + "staff_reservation_cancelled": "Prenotazione annullata", + "staff_reservation_reactivated": "Prenotazione riattivata", + "staff_reservation_updated": "Prenotazione aggiornata", + "staff_reservation_from": "Desiderato dal", + "staff_reservation_to": "Desiderato fino al", + "staff_reservation_expiry": "Scadenza prenotazione", + "staff_reservation_dates_hint": "Lascia le date vuote per mettersi in coda per la prossima copia libera.", + "staff_reservations_active": "Attive", + "staff_reservations_completed": "Completate", + "staff_reservations_cancelled": "Annullate", + "staff_reservations_all": "Tutte", + "staff_reservations_empty": "Nessuna prenotazione", + "staff_reservations_search": "Titolo o utente", + "staff_return_title": "Registra la restituzione", + "staff_err_idempotency_conflict": "Il server ha già ricevuto questa richiesta con dati diversi. Controlla il modulo e invialo di nuovo.", + "staff_copy_no_code": "Copia senza codice", + "staff_copy_edit": "Modifica copia", + "staff_copy_add_with_code": "Aggiungi una copia con il suo codice", + "staff_copy_code_new_hint": "Lascia vuoto per farla numerare dalla biblioteca.", + "staff_copy_status": "Stato", + "staff_copy_added": "Copia aggiunta", + "staff_copy_saved": "Copia salvata", + "staff_copy_deleted": "Copia eliminata", + "staff_copy_delete": "Elimina copia", + "staff_copy_delete_body": "Eliminare la copia %1$s? Si può eliminare solo una copia mai andata in prestito.", + "staff_copy_code_taken": "Il codice %1$s è già usato da un’altra copia.", + "staff_labels_book_pdf": "Etichetta del libro", + "staff_labels_copies_pdf": "Etichette delle copie", + "staff_labels_this_copy": "Etichetta di questa copia", + "staff_labels_ready": "Etichette", + "staff_series_new": "Nuova collana", + "staff_series_search": "Cerca collane", + "staff_series_empty": "Ancora nessuna collana", + "staff_series_book_count": "Libri: %1$d", + "staff_series_complete": "Completa", + "staff_series_exists": "Esiste già una collana «%1$s». Aprirla?", + "staff_series_open_existing": "Aprila", + "staff_series_type": "Tipo", + "staff_series_parent": "Fa parte di", + "staff_series_parent_hint": "Nome della collana più ampia (creata se manca).", + "staff_series_group": "Gruppo", + "staff_series_cycle": "Ciclo", + "staff_series_cycle_order": "Ordine", + "staff_series_rename_hint": "Per cambiare il nome usa Rinomina.", + "staff_series_saved": "Collana salvata", + "staff_series_edit": "Modifica i dettagli", + "staff_series_rename": "Rinomina", + "staff_series_renamed": "Collana rinominata", + "staff_series_rename_clash": "Esiste già una collana «%1$s»: unire le due collane?", + "staff_series_merge": "Unisci a un’altra collana", + "staff_series_merge_body": "I suoi libri passano alla collana scelta, poi questa viene eliminata.", + "staff_series_merge_into": "Unisci a %1$s", + "staff_series_merged": "Collane unite", + "staff_series_merged_count": "Collane unite. Libri spostati: %1$d", + "staff_series_delete": "Elimina collana", + "staff_series_delete_body": "Eliminare «%1$s»? I suoi libri (%2$d) restano in catalogo senza collana.", + "staff_series_parent_work": "Crea l’opera madre", + "staff_series_parent_work_body": "Una scheda di catalogo che raccoglie l’intera collana, come sul web.", + "staff_series_parent_work_create": "Crea", + "staff_series_parent_work_created": "Opera madre creata", + "staff_series_add_books": "Aggiungi libri", + "staff_series_books_added": "Libri aggiunti: %1$d", + "staff_series_books": "Libri (%1$d)", + "staff_series_children": "Collane interne", + "staff_series_numbers": "Numeri", + "staff_series_number_value": "N. %1$s", + "staff_series_no_number": "Senza numero", + "staff_series_number_hint": "Vuoto cancella il numero.", + "staff_series_number_saved": "Numero salvato", + "staff_series_remove_book": "Togli dalla collana", + "staff_series_book_removed": "Libro tolto dalla collana", + "staff_yes": "Sì", + "staff_more_actions": "Altre azioni", + "staff_expand": "Espandi", + "staff_collapse": "Comprimi", + "staff_merge_pick": "Scegli le schede", + "staff_merge_manual_hint": "Scegli due o più schede che sono la stessa, poi quale resta.", + "staff_merge_review": "Rivedi l’unione", + "staff_merge_suggested": "Possibili duplicati", + "staff_merge_no_duplicates": "Nessun autore duplicato trovato.", + "staff_merge_author_books": "%1$s · libri: %2$d", + "staff_merge_primary_hint": "La scheda selezionata resta; le altre vengono unite e i loro libri spostati.", + "staff_merge_new_name": "Nuovo nome (facoltativo)", + "staff_merge_new_name_hint": "Vuoto mantiene il nome della scheda che resta.", + "staff_merge_action": "Unisci", + "staff_merge_done": "Schede unite: %1$d", + "staff_merge_genre_hint": "I libri e i sottogeneri del primo genere passano al secondo, poi il primo viene eliminato.", + "staff_merge_genre_source": "Genere da eliminare", + "staff_merge_genre_target": "Genere che resta", + "staff_merge_genre_pick": "Scegli un genere", + "staff_merge_genre_done": "Generi uniti. Libri spostati: %1$d · sottogeneri spostati: %2$d", + "staff_merge_genre_descendant": "Un genere non si può unire a un suo sottogenere.", + "staff_import_field_authors": "Autori", + "staff_import_field_contributors": "Altri contributori", + "staff_import_field_publisher": "Editore", + "staff_import_field_genre": "Genere", + "staff_import_field_keywords": "Parole chiave", + "staff_import_field_description": "Descrizione", + "staff_import_field_isbn": "ISBN", + "staff_import_field_ean": "EAN", + "staff_import_field_title": "Titolo", + "staff_import_field_subtitle": "Sottotitolo", + "staff_import_field_year": "Anno", + "staff_import_field_language": "Lingua", + "staff_import_field_edition": "Edizione", + "staff_import_field_pages": "Pagine", + "staff_import_field_format": "Formato", + "staff_import_field_price": "Prezzo", + "staff_import_field_series": "Collana", + "staff_import_field_dewey": "Dewey", + "staff_import_kind_csv": "CSV", + "staff_import_kind_lt": "LibraryThing", + "staff_import_file": "File", + "staff_import_choose_file": "Scegli un file", + "staff_import_scraping": "Cerca online i dati mancanti", + "staff_import_scraping_hint": "Più lento: una riga alla volta, e ogni riga può richiedere tempo.", + "staff_import_update_fields": "Campi da aggiornare sui libri già in catalogo", + "staff_import_start": "Carica e importa", + "staff_import_example": "CSV di esempio", + "staff_import_open_jobs": "Importazioni da completare", + "staff_import_history": "Importazioni passate", + "staff_import_progress": "Righe: %1$d su %2$d", + "staff_import_when": "Avviata", + "staff_import_by": "Da", + "staff_import_result": "Esito", + "staff_import_counts": "%1$d nuovi, %2$d aggiornati, %3$d falliti", + "staff_import_errors_csv": "Scarica gli errori", + "staff_import_read_failed": "Non è stato possibile leggere il file.", + "staff_import_too_big": "Il file supera i 20 MB.", + "staff_import_wrong_ext_csv": "Scegli un file .csv.", + "staff_import_wrong_ext_lt": "Scegli un export di LibraryThing (.tsv, .csv o .txt).", + "staff_import_failed": "L’importazione non è potuta proseguire.", + "staff_import_status_running": "Importazione in corso…", + "staff_import_status_paused": "In pausa", + "staff_import_status_done": "Importazione completata", + "staff_import_status_failed": "Importazione non riuscita", + "staff_import_status_cancelled": "Importazione annullata", + "staff_import_imported": "Libri nuovi", + "staff_import_updated": "Libri aggiornati", + "staff_import_scraped": "Completati online", + "staff_import_authors_created": "Nuovi autori", + "staff_import_publishers_created": "Nuovi editori", + "staff_import_errors": "Errori", + "staff_import_pause": "Metti in pausa", + "staff_import_resume": "Riprendi", + "staff_import_cancel": "Annulla l’importazione", + "staff_covers_missing_title": "Copertine mancanti", + "staff_covers_missing_count": "Libri con ISBN ma senza copertina: %1$d", + "staff_covers_missing_unknown": "Conto i libri senza copertina…", + "staff_covers_fetch_missing": "Recupera le copertine mancanti", + "staff_covers_loop_progress": "%1$d di %2$d · trovate %3$d · non trovate %4$d", + "staff_covers_rate_wait": "Attendo un momento: il server limita le copertine recuperate al minuto.", + "staff_covers_stop": "Interrompi", + "staff_covers_server_sync": "Sincronizza tutte le copertine sul server", + "staff_covers_server_sync_body": "Il server recupera tutte le copertine mancanti in un colpo solo. Può richiedere fino a dieci minuti; tieni aperta l’app.", + "staff_covers_sync_done": "Copertine sincronizzate: %1$d su %2$d", + "staff_long_job_running": "Lavoro in corso sul server, può richiedere qualche minuto…", + "staff_enrich_title": "Arricchimento", + "staff_enrich_with_isbn": "Libri con ISBN", + "staff_enrich_missing_cover": "Senza copertina", + "staff_enrich_missing_description": "Senza descrizione", + "staff_enrich_pending": "Ancora da arricchire", + "staff_enrich_cron": "Arricchisci automaticamente", + "staff_enrich_cron_hint": "Il server completa un gruppo di libri secondo la sua pianificazione.", + "staff_enrich_run": "Esegui ora un gruppo", + "staff_enrich_run_body": "Il server arricchisce i prossimi 20 libri. Può richiedere un paio di minuti.", + "staff_enrich_done": "Libri arricchiti: %1$d su %2$d", + "staff_offers_pending": "Da valutare", + "staff_offers_accepted": "Accettate", + "staff_offers_received": "Ricevute", + "staff_offers_rejected": "Rifiutate", + "staff_offers_all": "Tutte", + "staff_offers_wanted": "Titoli cercati", + "staff_offers_search": "Titolo, autore o donatore", + "staff_offers_empty": "Nessuna proposta qui", + "staff_wanted_empty": "La biblioteca non cerca nessun titolo", + "staff_offer_from": "da %1$s", + "staff_offer_status_pending": "Da valutare", + "staff_offer_status_accepted": "Accettata", + "staff_offer_status_received": "Ricevuta", + "staff_offer_status_rejected": "Rifiutata", + "staff_offer_author": "Autore", + "staff_offer_publisher": "Editore", + "staff_offer_donor": "Donatore", + "staff_offer_for_title": "Per il titolo cercato", + "staff_offer_received_into": "Ricevuta in", + "staff_offer_accept": "Accetta", + "staff_offer_reject": "Rifiuta", + "staff_offer_receive": "Ricevuta: aggiungi la copia", + "staff_offer_receive_into": "Quale scheda riceve la copia?", + "staff_offer_receive_into_hint": "Questa proposta non riguarda un titolo cercato: scegli la scheda a cui appartiene la copia.", + "staff_offer_accepted": "Proposta accettata", + "staff_offer_rejected": "Proposta rifiutata", + "staff_offer_received": "Copia aggiunta al catalogo", + "staff_offer_delete": "Elimina proposta", + "staff_offer_delete_body": "Eliminare la proposta per «%1$s»? Non si può annullare.", + "staff_offer_deleted": "Proposta eliminata", + "staff_wanted_open_offers_count": "Proposte aperte: %1$d", + "staff_wanted_mark_received": "Ricevuto", + "staff_wanted_received": "«%1$s» ricevuto: copia aggiunta", + "staff_wanted_open_offers": "Questo titolo ha proposte aperte: ricevilo dalla proposta.", + "staff_archives_search": "Titolo o segnatura", + "staff_archives_filter_all": "Tutte", + "staff_archives_filter_published": "Pubblicate", + "staff_archives_filter_draft": "Non pubblicate", + "staff_archives_empty": "Nessuna unità archivistica", + "staff_archive_new": "Nuova unità archivistica", + "staff_archive_edit": "Modifica unità archivistica", + "staff_archive_published": "Pubblicata", + "staff_archive_draft": "Non pubblicata", + "staff_archive_published_hint": "Visibile nell’archivio pubblico e nell’app.", + "staff_archive_section_identity": "Identificazione", + "staff_archive_section_dates": "Date", + "staff_archive_section_description": "Descrizione", + "staff_archive_section_material": "Materiale", + "staff_archive_section_links": "Collegamenti e identificativi", + "staff_archive_section_authorities": "Autorità", + "staff_archive_section_files": "Copertina e documenti", + "staff_archive_reference_code": "Segnatura *", + "staff_archive_institution_code": "Codice istituto", + "staff_archive_level": "Livello *", + "staff_archive_constructed_title": "Titolo *", + "staff_archive_formal_title": "Titolo formale", + "staff_archive_parent_id": "Numero dell’unità madre", + "staff_archive_date_start": "Dal (anno)", + "staff_archive_date_end": "Al (anno)", + "staff_archive_extent": "Consistenza", + "staff_archive_scope_content": "Ambito e contenuto", + "staff_archive_language_codes": "Lingue (codici)", + "staff_archive_material": "Materiale specifico", + "staff_archive_color_mode": "Colore", + "staff_archive_dimensions": "Dimensioni", + "staff_archive_photographer": "Fotografo", + "staff_archive_publisher": "Editore", + "staff_archive_collection_name": "Raccolta", + "staff_archive_local_classification": "Classificazione locale", + "staff_archive_iiif": "URL del manifest IIIF", + "staff_archive_rights": "URL della dichiarazione dei diritti", + "staff_archive_ark": "Identificativo ARK", + "staff_archive_version_note": "Nota di versione", + "staff_archive_role": "Ruolo della prossima autorità", + "staff_archive_attach_authority": "Collega un’autorità", + "staff_archive_attach_role_hint": "Verrà collegata come: %1$s", + "staff_archive_detach": "Scollega %1$s", + "staff_archive_no_cover": "Ancora nessuna copertina.", + "staff_archive_has_cover": "La copertina è impostata.", + "staff_archive_upload_cover": "Carica una copertina", + "staff_archive_remove_cover": "Rimuovi la copertina", + "staff_archive_upload_document": "Allega un documento", + "staff_archive_remove_document": "Rimuovi il documento", + "staff_archive_save_first": "Salva prima l’unità per collegare autorità, copertina o documenti.", + "staff_archive_created": "Unità archivistica creata", + "staff_archive_saved": "Unità archivistica salvata", + "staff_archive_duplicate": "Questa segnatura è già usata in questo istituto.", + "staff_archive_cover_uploaded": "Copertina caricata", + "staff_archive_cover_removed": "Copertina rimossa", + "staff_archive_document_uploaded": "Documento allegato", + "staff_archive_document_removed": "Documento rimosso", + "staff_archive_authority_attached": "Autorità collegata", + "staff_archive_authority_detached": "Autorità scollegata", + "staff_archive_delete": "Elimina unità", + "staff_archive_delete_body": "Eliminare «%1$s» e i suoi allegati?", + "staff_authorities_search": "Cerca autorità", + "staff_authorities_empty": "Nessuna scheda di autorità", + "staff_authority_units": "Unità: %1$d", + "staff_authority_new": "Nuova autorità", + "staff_authority_edit": "Modifica autorità", + "staff_authority_delete": "Elimina autorità", + "staff_authority_delete_body": "Eliminare «%1$s»? Viene scollegata da tutte le unità.", + "staff_authority_type": "Tipo *", + "staff_authority_authorised_form": "Forma autorizzata del nome *", + "staff_authority_parallel_forms": "Forme parallele", + "staff_authority_other_forms": "Altre forme", + "staff_authority_identifiers": "Identificativi", + "staff_authority_dates": "Date di esistenza", + "staff_authority_history": "Storia", + "staff_authority_places": "Luoghi", + "staff_authority_legal_status": "Condizione giuridica", + "staff_authority_functions": "Funzioni e attività", + "staff_authority_mandates": "Mandati", + "staff_authority_internal_structure": "Struttura interna", + "staff_authority_general_context": "Contesto generale", + "staff_authority_linked_units": "Unità collegate", + "staff_export_librarything": "Esporta per LibraryThing", + "staff_merge_already_done": "Queste schede erano già state unite.", + "staff_err_cover_download_failed": "È stata trovata una copertina ma non è stato possibile scaricarla.", + "staff_digital_title": "Contenuti digitali", + "staff_digital_hint": "Allega l’eBook o l’audiolibro che i lettori aprono dalla scheda del libro.", + "staff_digital_ebook": "Carica un eBook (PDF o EPUB)", + "staff_digital_audio": "Carica un audiolibro", + "staff_digital_uploading": "Caricamento…", + "staff_digital_ebook_done": "eBook allegato", + "staff_digital_audio_done": "Audiolibro allegato", + "staff_digital_failed": "Non è stato possibile allegare il file.", + "staff_select_all": "Seleziona tutti", + "staff_import_status_gone": "Questa importazione è terminata altrove", + "staff_archive_document_too_big": "Il documento supera i 200 MB.", + "staff_periodicals": "Banco emeroteca", + "staff_periodicals_subtitle": "Ricevi e reclama fascicoli, annate, abbonamenti", + "staff_periodicals_scan_permission": "La fotocamera legge il codice a barre del fascicolo. Puoi anche scriverlo qui sotto.", + "staff_periodicals_code": "Codice a barre del fascicolo o ISSN", + "staff_periodicals_mastheads": "Testate", + "staff_periodicals_search": "Cerca una testata", + "staff_periodicals_counts": "Annate: %1$d · fascicoli: %2$d", + "staff_periodicals_open": "Apri la testata", + "staff_periodicals_title_only": "Nessun fascicolo ha questo codice, ma appartiene a questa testata.", + "staff_periodicals_ambiguous": "Più fascicoli hanno questo codice", + "staff_periodicals_no_match": "Nessuna corrispondenza per questo codice", + "staff_periodicals_frequency": "Periodicità", + "staff_periodicals_holdings": "Consistenza", + "staff_periodicals_years": "Annate", + "staff_periodicals_year_counts": "Possedute: %1$d su %2$d", + "staff_issue_line": "N. %1$s · %2$d", + "staff_issue_number": "N. %1$s", + "staff_issue_number_label": "Numero del fascicolo *", + "staff_issue_date": "Data di pubblicazione", + "staff_issue_receive": "Ricevuto", + "staff_issue_claim": "Reclama", + "staff_issue_add": "Aggiungi un fascicolo", + "staff_issue_added": "Fascicolo aggiunto", + "staff_issue_received": "N. %1$s di %2$s ricevuto", + "staff_issue_claimed": "N. %1$s di %2$s reclamato", + "staff_issue_status_owned": "Posseduto", + "staff_issue_status_missing": "Mancante", + "staff_issue_status_expected": "Atteso", + "staff_issue_status_lost": "Smarrito", + "staff_issue_status_claimed": "Reclamato", + "staff_issue_status_discarded": "Scartato", + "staff_year_add": "Aggiungi un’annata", + "staff_year_added": "Annata aggiunta", + "staff_year_label": "Anno *", + "staff_year_volume": "Volume", + "staff_year_bound": "Rilegata in un volume", + "staff_subscriptions": "Abbonamenti", + "staff_subscription_add": "Aggiungi un abbonamento", + "staff_subscription_edit": "Modifica abbonamento", + "staff_subscription_saved": "Abbonamento salvato", + "staff_subscription_until": "Fino al %1$s", + "staff_subscription_inactive": "Non attivo", + "staff_subscription_supplier": "Fornitore *", + "staff_subscription_cost": "Costo", + "staff_subscription_currency": "Valuta", + "staff_subscription_start": "Inizio", + "staff_subscription_end": "Scadenza", + "staff_subscription_auto_renew": "Rinnovo automatico", + "staff_subscription_active": "Attivo", + "staff_clubs": "Gruppi di lettura", + "staff_clubs_subtitle": "Impostazioni, percorso di lettura e membri", + "staff_clubs_empty": "Ancora nessun gruppo di lettura", + "staff_club_new": "Nuovo gruppo", + "staff_club_edit": "Modifica impostazioni", + "staff_club_saved": "Gruppo salvato", + "staff_club_delete": "Elimina gruppo", + "staff_club_delete_body": "Eliminare «%1$s» con le proposte e le discussioni?", + "staff_club_rules": "Regole", + "staff_club_privacy": "Visibilità", + "staff_club_privacy_public": "Pubblico", + "staff_club_privacy_private": "Privato", + "staff_club_privacy_invite": "Su invito", + "staff_club_privacy_hidden": "Nascosto", + "staff_club_color": "Colore", + "staff_club_max_members": "Membri massimi", + "staff_club_active": "Attivo", + "staff_club_inactive": "Non attivo", + "staff_club_moderate": "Modera le proposte", + "staff_club_members_count": "Membri: %1$d", + "staff_club_workflow": "Percorso di lettura", + "staff_club_workflow_edit": "Modifica il percorso", + "staff_club_workflow_hint": "Almeno due stati. Indica dove si vota, cosa si sta leggendo e cosa è archiviato.", + "staff_club_workflow_saved": "Percorso salvato", + "staff_club_state_label": "Stato", + "staff_club_state_add": "Aggiungi uno stato", + "staff_club_state_new": "Nuovo stato", + "staff_club_flag_voting": "Votazione", + "staff_club_flag_current": "In lettura", + "staff_club_flag_archived": "Archiviato", + "staff_move_up": "Sposta su", + "staff_move_down": "Sposta giù", + "staff_club_members": "Membri", + "staff_club_role": "Ruolo", + "staff_club_role_owner": "Proprietario", + "staff_club_role_moderator": "Moderatore", + "staff_club_role_member": "Membro", + "staff_club_role_guest": "Ospite", + "staff_club_member_status": "Iscrizione", + "staff_club_member_pending": "In attesa", + "staff_club_member_active": "Attivo", + "staff_club_member_suspended": "Sospeso", + "staff_club_member_left": "Uscito", + "staff_club_member_banned": "Escluso", + "staff_club_member_saved": "Membro aggiornato", + "staff_club_member_add": "Aggiungi un membro", + "staff_club_member_add_hint": "Entra come: %1$s", + "staff_club_member_added": "%1$s aggiunto al gruppo", + "staff_works": "Opere (FRBR)", + "staff_works_subtitle": "L’opera dietro edizioni e traduzioni", + "staff_works_search": "Cerca opere", + "staff_works_empty": "Ancora nessuna opera", + "staff_work_new": "Nuova opera", + "staff_work_edit": "Modifica l’opera", + "staff_work_saved": "Opera salvata", + "staff_work_delete": "Elimina opera", + "staff_work_delete_body": "Eliminare «%1$s»? I suoi libri restano in catalogo senza opera.", + "staff_work_uniform_title": "Titolo uniforme *", + "staff_work_original_title": "Titolo originale", + "staff_work_main_author": "Autore principale", + "staff_work_created_from": "Creata dal (anno)", + "staff_work_created_to": "Creata fino al (anno)", + "staff_work_created": "Creazione", + "staff_work_language": "Lingua", + "staff_work_editions_count": "Edizioni: %1$d", + "staff_work_expressions": "Espressioni", + "staff_work_books": "Libri di quest’opera", + "staff_work_attach": "Collega un libro", + "staff_work_detach": "Scollega il libro", + "staff_work_book_attached": "Libro collegato all’opera", + "staff_work_book_detached": "Libro scollegato", + "staff_expression_add": "Aggiungi un’espressione", + "staff_expression_edit": "Modifica espressione", + "staff_expression_delete": "Elimina espressione", + "staff_expression_saved": "Espressione salvata", + "staff_expression_deleted": "Espressione eliminata", + "staff_expression_type": "Tipo", + "staff_expression_text": "Testo", + "staff_expression_translation": "Traduzione", + "staff_expression_revision": "Revisione", + "staff_expression_adaptation": "Adattamento", + "staff_expression_critical": "Edizione critica", + "staff_expression_audio": "Audio", + "staff_expression_other": "Altro", + "staff_field_title_plain": "Titolo" } diff --git a/tools/e2e/live-smoke.sh b/tools/e2e/live-smoke.sh new file mode 100755 index 0000000..066ce39 --- /dev/null +++ b/tools/e2e/live-smoke.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Live end-to-end smoke test of the Android app against a real Pinakes instance. +# +# PINAKES_E2E_URL=http://10.0.2.2:8081 PINAKES_E2E_EMAIL=... PINAKES_E2E_PASSWORD=... \ +# ANDROID_SERIAL=emulator-5554 tools/e2e/live-smoke.sh [--install] +# +# --install builds and installs the debug APK first. The device is a disposable test device: +# the app's data is cleared at the start and at the end. Everything the run creates on the +# server (books, calendar link, device tokens) is removed again. See tools/e2e/live_smoke.py. +set -euo pipefail +cd "$(dirname "$0")/../.." + +for var in PINAKES_E2E_URL PINAKES_E2E_EMAIL PINAKES_E2E_PASSWORD; do + if [ -z "${!var:-}" ]; then echo "missing environment variable $var" >&2; exit 2; fi +done +command -v adb >/dev/null || { echo "adb not on PATH" >&2; exit 2; } +command -v python3 >/dev/null || { echo "python3 not on PATH" >&2; exit 2; } +adb ${ANDROID_SERIAL:+-s "$ANDROID_SERIAL"} get-state >/dev/null + +if [ "${1:-}" = "--install" ]; then + ./gradlew installDebug --console=plain -q +fi +adb ${ANDROID_SERIAL:+-s "$ANDROID_SERIAL"} shell pm path com.pinakes.app >/dev/null || { + echo "com.pinakes.app is not installed on the device (use --install)" >&2; exit 2; } + +exec python3 -u tools/e2e/live_smoke.py diff --git a/tools/e2e/live_smoke.py b/tools/e2e/live_smoke.py new file mode 100755 index 0000000..8e85a35 --- /dev/null +++ b/tools/e2e/live_smoke.py @@ -0,0 +1,918 @@ +#!/usr/bin/env python3 +"""Live end-to-end smoke test: the real app on an emulator against a real Pinakes instance. + +Drives the installed debug app with adb/uiautomator (``ui.py``) like a user, checks the +server state through the Mobile API after every step, and removes what it created. + +Environment (nothing is hardcoded, secrets are never printed): + PINAKES_E2E_URL instance URL as the device reaches it (emulator: http://10.0.2.2:8081) + PINAKES_E2E_HOST_URL same instance as this machine reaches it (default: the device URL + with 10.0.2.2 replaced by 127.0.0.1) + PINAKES_E2E_EMAIL an admin or staff account + PINAKES_E2E_PASSWORD its password + PINAKES_E2E_ISBN ISBN to scan-add (default 9788845292613); must not be in the catalogue + ANDROID_SERIAL target device (adb's own variable) + +Optional, for the "hidden profile fields" step (skipped without them): direct access to the +instance database, used to switch the gender / tax code visibility and restore it afterwards. + PINAKES_E2E_DB_NAME, PINAKES_E2E_DB_USER, PINAKES_E2E_DB_PASSWORD, + PINAKES_E2E_DB_SOCKET (or PINAKES_E2E_DB_HOST + PINAKES_E2E_DB_PORT) + +Steps: onboarding URL, login, staff hub, manual ISBN add (scraped preview), duplicate add +copies, manual record without ISBN, shelf/level location on edit (skipped when the instance +has no shelf with a level), phase 2 (each skipped while its /health staff_* flag is off): +loan created by API with an Idempotency-Key and its replay, then returned in the app by +typing the copy code; a copy with an explicit code from the book editor; a series created in +the app; a two-row CSV import job followed to completion; a free Desiderata offer received into a picked record; an +unpublished archive unit created and checked absent from the public list; Tier 2: an unknown +code looked up at the periodicals desk (read-only), a book club and an FRBR work created in the +app; then airplane-mode +offline search and detail, calendar link + feed. +Cleanup (only what this run created): created books deleted, the calendar link revoked when +it did not exist before, device tokens issued during the run revoked, app data cleared, +airplane mode off. Exit code 0 only when every step passed. +""" +import json +import os +import random +import re +import subprocess +import sys +import time +import traceback +import urllib.error +import urllib.request +import uuid + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import ui # noqa: E402 + +PKG = "com.pinakes.app" +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def env(name, default=None, required=True): + value = os.environ.get(name, default) + if required and not value: + sys.exit(f"missing environment variable {name}") + return value + + +DEVICE_URL = env("PINAKES_E2E_URL").rstrip("/") +HOST_URL = env("PINAKES_E2E_HOST_URL", DEVICE_URL.replace("10.0.2.2", "127.0.0.1")).rstrip("/") +EMAIL = env("PINAKES_E2E_EMAIL") +PASSWORD = env("PINAKES_E2E_PASSWORD") +ISBN = env("PINAKES_E2E_ISBN", "9788845292613") +RUN = time.strftime("%Y%m%d%H%M%S") +MANUAL_TITLE = f"E2E smoke {RUN}" + + +# ---------------------------------------------------------------- strings (device language) + +def load_strings(): + locale = ui.shell("getprop persist.sys.locale").strip() or ui.shell("getprop ro.product.locale").strip() + lang = (locale or "en").split("-")[0].lower() + path = os.path.join(ROOT, "i18n", f"{lang}.json") + if not os.path.exists(path): + path = os.path.join(ROOT, "i18n", "en.json") + with open(os.path.join(ROOT, "i18n", "en.json"), encoding="utf-8") as fh: + base = json.load(fh) + with open(path, encoding="utf-8") as fh: + base.update(json.load(fh)) + return base + + +S = {} + + +def s(key, *args): + value = S[key] + for i, a in enumerate(args, 1): + value = re.sub(r"%" + str(i) + r"\$[sd]", str(a), value) + return value + + +def prefix(key): + """The text of a format string before its first placeholder (for substring matching).""" + return re.split(r"%\d\$[sd]", S[key])[0].strip() + + +# ---------------------------------------------------------------- API + +class Api: + def __init__(self): + self.token = None + + def call(self, method, path, body=None, raw=False, auth=True, base=None, headers=None, multipart=None): + url = (base or HOST_URL) + "/api/v1/" + path.lstrip("/") if not path.startswith("http") else path + data = json.dumps(body).encode() if body is not None else None + content_type = "application/json" + if multipart is not None: + data, content_type = encode_multipart(multipart) + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Accept", "application/json") + for k, v in (headers or {}).items(): + req.add_header(k, v) + if data is not None: + req.add_header("Content-Type", content_type) + if auth and self.token: + req.add_header("Authorization", "Bearer " + self.token) + try: + with urllib.request.urlopen(req, timeout=180) as res: + payload = res.read() + return res.status, (payload if raw else json.loads(payload or b"{}")), res.headers + except urllib.error.HTTPError as e: + payload = e.read() + try: + return e.code, (payload if raw else json.loads(payload or b"{}")), e.headers + except ValueError: + return e.code, payload, e.headers + + def login(self): + status, body, _ = self.call("POST", "auth/login", { + "email": EMAIL, "password": PASSWORD, "device_name": f"e2e-smoke-{RUN}", + "device_id": f"e2e-{RUN}-{random.randint(1000, 9999)}", "platform": "other", + }, auth=False) + if status != 200 or not body.get("data", {}).get("token"): + raise AssertionError(f"API login failed with HTTP {status}") + self.token = body["data"]["token"] + role = body["data"]["user"].get("tipo_utente") + if role not in ("admin", "staff"): + raise AssertionError(f"PINAKES_E2E_EMAIL must be admin or staff (is {role!r})") + + def find_book(self, q): + status, body, _ = self.call("GET", f"staff/books?q={urllib.request.quote(q)}&limit=20") + assert status == 200, f"GET staff/books HTTP {status}" + return body.get("data") or [] + + def book(self, book_id): + status, body, _ = self.call("GET", f"staff/books/{book_id}") + assert status == 200, f"GET staff/books/{book_id} HTTP {status}" + return body["data"] + + +def encode_multipart(fields): + """fields: {name: str | (filename, bytes, mime)} → (body, content type).""" + boundary = "e2e" + RUN + str(random.randint(1000, 9999)) + out = b"" + for name, value in fields.items(): + out += f"--{boundary}\r\n".encode() + if isinstance(value, tuple): + filename, payload, mime = value + out += f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\nContent-Type: {mime}\r\n\r\n'.encode() + out += payload + b"\r\n" + else: + out += f'Content-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode() + out += f"--{boundary}--\r\n".encode() + return out, f"multipart/form-data; boundary={boundary}" + + +API = Api() +CREATED = [] # book ids to delete +CREATED_SERIES = [] # series ids to delete +CREATED_ARCHIVES = [] # archive unit ids to delete +CREATED_OFFERS = [] # desiderata offer ids to delete +CREATED_CLUBS = [] # book club ids to delete +CREATED_WORKS = [] # FRBR work ids to delete +CLUB_NAME = f"E2E club {RUN}" +WORK_TITLE = f"E2E work {RUN}" +CIRC_TITLE = f"E2E circulation {RUN}" +SERIES_NAME = f"E2E series {RUN}" +IMPORT_TITLES = (f"E2E import A {RUN}", f"E2E import B {RUN}") +ARCHIVE_REF = f"E2E-{RUN}" +RESULTS = [] # (step, ok, detail) +DEVICES_BEFORE = set() +STATE = {"devices_known": False, "calendar_was_enabled": None} + + +def step(name): + def wrap(fn): + def run(*a, **kw): + print(f"--> {name}", flush=True) + try: + detail = fn(*a, **kw) + RESULTS.append((name, "PASS", detail or "")) + print(f" PASS {detail or ''}", flush=True) + return True + except SkipStep as e: + RESULTS.append((name, "SKIP", str(e))) + print(f" SKIP {e}", flush=True) + return True + except Exception as e: # noqa: BLE001 - report and continue with the next step + RESULTS.append((name, "FAIL", str(e))) + print(f" FAIL {e}", flush=True) + traceback.print_exc() + try: + ui.screenshot(f"/tmp/pinakes-e2e-{RUN}-{len(RESULTS)}.png") + except Exception: # noqa: BLE001 + pass + return False + return run + return wrap + + +class SkipStep(Exception): + pass + + +# ---------------------------------------------------------------- navigation helpers + +def open_tab(key): + """Go back to the bottom-navigation screen if needed, then open the tab [key].""" + w, h = ui.screen_size() + for _ in range(6): + ui.hide_keyboard() + for n in ui.dump(): + # Bottom navigation labels sit in the lowest part of the screen. + if n.text == S[key] and n.center[1] > h * 0.8: + ui.tap_xy(*n.center) + return + ui.back() + raise AssertionError(f"bottom navigation not reachable for {S[key]!r}") + + +def open_staff_hub(): + open_tab("nav_profile") + ui.wait(S["profile_action_edit"], 90) + ui.scroll_to(S["profile_action_staff"]) + ui.tap(S["profile_action_staff"]) + ui.wait(S["staff_scan"]) + + +def deny_camera_prompt(): + for res in ("com.android.permissioncontroller:id/permission_deny_button", + "com.android.permissioncontroller:id/permission_deny_and_dont_ask_again_button"): + deadline = time.time() + 4 + while time.time() < deadline: + n = ui.find_res(res) + if n: + ui.tap_xy(*n.center) + return + time.sleep(0.5) + + +def scanner_lookup(code): + ui.tap(S["staff_scan_manual_label"]) + ui.type_text(code) + ui.hide_keyboard() + ui.tap(S["staff_scan_lookup"]) + + +# ---------------------------------------------------------------- steps + +@step("preflight: API login and clean ISBN") +def preflight(): + API.login() + # Snapshot what already exists, so cleanup only removes what this run created. + _, devs, _ = API.call("GET", "me/devices") + DEVICES_BEFORE.update(d["id"] for d in devs.get("data") or []) + STATE["devices_known"] = True + status, cal, _ = API.call("GET", "me/calendar") + if status == 200: + STATE["calendar_was_enabled"] = bool((cal.get("data") or {}).get("enabled")) + status, body, _ = API.call("GET", "health", auth=False) + feats = body["data"]["features"] + assert feats.get("staff_catalog") and feats.get("catalog_sync"), f"server lacks Mobile API 1.6.0 features: {feats}" + if any(r.get("isbn13") == ISBN or r.get("ean") == ISBN for r in API.find_book(ISBN)): + raise AssertionError(f"ISBN {ISBN} is already in the catalogue: set PINAKES_E2E_ISBN to an unused one") + return f"calendar_feed={feats.get('calendar_feed')}" + + +@step("onboarding and login in the app") +def onboarding_and_login(): + ui.adb("shell", "pm", "clear", PKG) + ui.airplane(False) + ui.shell(f"monkey -p {PKG} -c android.intent.category.LAUNCHER 1") + ui.wait(S["onboarding_url_label"], 30) + ui.tap(S["onboarding_url_label"]) + ui.type_text(DEVICE_URL) + ui.hide_keyboard() + ui.tap(S["action_discover_library"]) + ui.wait(S["action_continue"], 90) # discovery on a loaded dev server + ui.tap(S["action_continue"]) + ui.wait(S["login_email_label"], 30) + ui.tap(S["login_email_label"], exact=True) + ui.type_text(EMAIL) + ui.hide_keyboard() + ui.tap(S["field_password"], exact=True) + ui.type_text(PASSWORD, secret=True) + ui.hide_keyboard() + # A loaded dev server can time out the first request: retry the sign-in like a user would. + for attempt in range(3): + ui.tap(S["login_action_sign_in"]) + which, _ = ui.wait_any([S["nav_profile"], S["login_error_network"]], 120) + if which == S["nav_profile"]: + return f"signed in (attempt {attempt + 1})" + time.sleep(5) + raise AssertionError("sign-in kept failing with a network error") + + +@step("staff hub is reachable") +def staff_hub(): + open_staff_hub() + for key in ("staff_add_manual", "staff_books", "staff_authors", "staff_shelves"): + ui.scroll_to(S[key]) + + +@step("scan: manual ISBN, scraped preview, add with 2 copies") +def scan_add(): + ui.scroll_to(S["staff_scan"], exact=True) + ui.tap(S["staff_scan"], exact=True) + deny_camera_prompt() + ui.wait(S["staff_scan_manual_label"]) + scanner_lookup(ISBN) + found, _ = ui.wait_any([S["staff_scan_confirm"], S["staff_scan_not_found"], S["staff_scan_duplicate"]], 90) + assert found == S["staff_scan_confirm"], f"expected a preview for {ISBN}, got {found!r}" + time.sleep(1.5) # let the sheet finish expanding + ui.tap(S["staff_cd_increase"]) # 1 → 2 copies + ui.tap(S["staff_scan_confirm"]) + which, _ = ui.wait_any([s("staff_scan_session_count", 1), S["staff_error_save"], S["staff_error_offline"]], 150) + rows = [r for r in API.find_book(ISBN) if r.get("isbn13") == ISBN or r.get("ean") == ISBN] + CREATED.extend(r["id"] for r in rows if r["id"] not in CREATED) + assert which == s("staff_scan_session_count", 1), f"the app reported {which!r} (server rows: {len(rows)})" + assert len(rows) == 1, f"expected one record with {ISBN}, found {len(rows)}" + book = API.book(rows[0]["id"]) + assert book["copies_total"] == 2, f"copies_total {book['copies_total']} != 2" + return f"book #{book['id']} {book['title']!r}" + + +@step("scan the same ISBN: duplicate sheet adds a copy") +def scan_duplicate(): + if not CREATED: + raise SkipStep("no scanned book") + scanner_lookup(ISBN) + ui.wait(S["staff_scan_duplicate"], 60) + ui.tap(S["staff_scan_add_copy"]) + ui.wait(s("staff_scan_added_copies", 1), 30) + book = API.book(CREATED[0]) + assert book["copies_total"] == 3, f"copies_total {book['copies_total']} != 3" + ui.back() + + +@step("manual record without ISBN") +def manual_record(): + ui.scroll_to(S["staff_add_manual"], exact=True) + ui.tap(S["staff_add_manual"], exact=True) + ui.wait(S["staff_field_title"]) + ui.tap(S["staff_field_title"]) + ui.type_text(MANUAL_TITLE) + ui.hide_keyboard() + ui.tap(S["staff_book_save"]) + ui.wait(S["staff_add_manual"], 120) # back on the hub after the save + rows = [r for r in API.find_book(MANUAL_TITLE) if r["title"] == MANUAL_TITLE] + CREATED.extend(r["id"] for r in rows if r["id"] not in CREATED) + assert len(rows) == 1, f"expected one {MANUAL_TITLE!r}, found {len(rows)}" + book = API.book(rows[0]["id"]) + assert not book.get("isbn13") and not book.get("ean"), "manual record got an identifier" + return f"book #{book['id']}" + + +@step("edit location: shelf + level, everything else unchanged") +def edit_location(): + status, body, _ = API.call("GET", "staff/shelves") + shelves = [sh for sh in body.get("data") or [] if sh.get("levels")] + if not shelves: + raise SkipStep("the instance has no shelf with a level") + if len(CREATED) < 2: + raise SkipStep("no manual record") + shelf, level = shelves[0], shelves[0]["levels"][0] + before = API.book(CREATED[1]) + ui.scroll_to(S["staff_books"], exact=True) + ui.tap(S["staff_books"], exact=True) + ui.tap(S["staff_books_search"]) + ui.type_text(MANUAL_TITLE) + ui.hide_keyboard() + ui.tap(MANUAL_TITLE, timeout=30) + ui.wait(S["staff_book_save"], 30) + ui.scroll_to(S["staff_shelf"]) + ui.tap(S["staff_shelf"]) + display = " · ".join(x for x in (shelf.get("code"), shelf.get("name")) if x) + ui.tap(display) + ui.scroll_to(S["staff_level"]) + ui.tap(S["staff_level"]) + ui.tap(s("staff_level_number", level["number"])) + ui.tap(S["staff_book_save"]) + # The save button shows a spinner (no text) while saving: wait for the editor itself to close. + ui.gone(S["staff_book_edit"], 120) + after = API.book(CREATED[1]) + loc = after.get("location") or {} + assert loc.get("shelf_id") == shelf["id"] and loc.get("level_id") == level["id"], f"location {loc}" + for key in ("title", "subtitle", "isbn13", "ean", "language", "media_type", "copies_total", "authors", "publishers", "genre"): + assert before.get(key) == after.get(key), f"{key} changed: {before.get(key)!r} → {after.get(key)!r}" + ui.back() + return f"{display} / level {level['number']}" + + +@step("offline: airplane mode, cached search and detail") +def offline(): + # Foreground again so the app syncs the catalogue with the records created above. + ui.shell("input keyevent KEYCODE_HOME") + time.sleep(1) + ui.shell(f"monkey -p {PKG} -c android.intent.category.LAUNCHER 1") + time.sleep(25) # the foreground sync (slow dev servers need time) + ui.airplane(True) + try: + open_tab("nav_catalog") + ui.tap(S["catalog_field_placeholder"]) + ui.type_text(MANUAL_TITLE) + ui.hide_keyboard() + ui.wait(S["offline_results"], 30) + ui.tap(MANUAL_TITLE, timeout=30) + ui.wait(S["offline_actions_unavailable"], 30) + # The availability box is below the cover: scroll to it, checking each screen on the way. + last_known = prefix("offline_availability_last_known") + for _ in range(8): + nodes = ui.dump() + for bad in (S["book_request_loan"], S["book_reserve"], "Failed to connect"): + assert not ui.find(bad, nodes=nodes), f"{bad!r} shown offline" + if ui.find(last_known, nodes=nodes) or ui.find(S["offline_availability_unknown"], nodes=nodes): + break + ui.swipe_up() + else: + raise AssertionError("no 'last known' availability wording on the offline page") + ui.back() + finally: + ui.airplane(False) + + +@step("calendar: create link, fetch the feed") +def calendar(): + status, body, _ = API.call("GET", "health", auth=False) + if not body["data"]["features"].get("calendar_feed"): + raise SkipStep("calendar_feed is off on this instance") + open_tab("nav_profile") + ui.wait(S["profile_action_edit"], 90) # the profile has loaded + ui.scroll_to(S["profile_action_calendar"]) + ui.tap(S["profile_action_calendar"]) + which, _ = ui.wait_any([S["calendar_enable"], S["calendar_copy_link"]], 30) + if which == S["calendar_enable"]: + ui.tap(S["calendar_enable"]) + STATE["calendar_created"] = True + ui.wait(S["calendar_copy_link"], 30) + status, body, _ = API.call("GET", "me/calendar") + data = body["data"] + assert data["enabled"] and data["feed_url"], f"calendar status {data}" + feed = data["feed_url"].replace("10.0.2.2", "127.0.0.1") if "127.0.0.1" in HOST_URL else data["feed_url"] + status, payload, headers = API.call("GET", feed, raw=True, auth=False) + assert status == 200, f"feed HTTP {status}" + assert b"BEGIN:VCALENDAR" in payload, "feed is not iCalendar" + assert "text/calendar" in (headers.get("Content-Type") or ""), "feed content type" + ui.back() + + +# ---------------------------------------------------------------- phase 2 (Mobile API 1.7.0) + +def staff_flag(name): + _, body, _ = API.call("GET", "health", auth=False) + return bool((body.get("data") or {}).get("features", {}).get(name)) + + +def on_hub(nodes=None): + """The hub: its title is on screen and the bottom navigation is not (Profile shows both).""" + nodes = nodes if nodes is not None else ui.dump() + return ui.find(S["staff_title"], exact=True, nodes=nodes) and not ui.find(S["nav_profile"], exact=True, nodes=nodes) + + +def back_to_hub(): + for _ in range(6): + if on_hub(): + return + if ui.find(S["nav_profile"], exact=True): + break + ui.hide_keyboard() + ui.back() + open_staff_hub() + + +@step("circulation: loan by API, return by typed copy code in the app") +def circulation_return(): + if not staff_flag("staff_circulation"): + raise SkipStep("staff_circulation is off on this instance") + _, me, _ = API.call("GET", "me") + user_id = me["data"]["id"] + status, book, _ = API.call("POST", "staff/books", {"title": CIRC_TITLE, "copies": 1}) + assert status in (200, 201), f"create book HTTP {status}" + CREATED.append(book["data"]["id"]) + code = API.book(book["data"]["id"])["copies"][0]["inventory_number"] + key = f"e2e-{RUN}-loan" + status, loan, _ = API.call("POST", "staff/loans", {"user_id": user_id, "book_id": book["data"]["id"], "immediate": True, + "copy_code": code}, headers={"Idempotency-Key": key}) + assert status in (200, 201), f"create loan HTTP {status}: {loan.get('error')}" + loan_id = loan["data"]["id"] + # The same submission again is a replay, not a second loan. + status, again, _ = API.call("POST", "staff/loans", {"user_id": user_id, "book_id": book["data"]["id"], "immediate": True, + "copy_code": code}, headers={"Idempotency-Key": key}) + assert status == 200 and again["data"]["id"] == loan_id, f"replay HTTP {status}" + back_to_hub() + ui.scroll_to(S["staff_scan_copy"]) + ui.tap(S["staff_scan_copy"]) + deny_camera_prompt() + ui.tap(S["staff_copy_code"]) + ui.type_text(code) + ui.hide_keyboard() + ui.tap(S["staff_scan_lookup"]) + ui.wait(CIRC_TITLE, 30) + ui.tap(S["staff_loan_action_return"], exact=True) + ui.wait(S["staff_return_title"], 15) + time.sleep(1.5) + nodes = ui.dump() + buttons = [n for n in nodes if n.text == S["staff_loan_action_return"]] + ui.tap_xy(*buttons[-1].center) + ui.wait(s("staff_copy_returned_count", 1), 30) + _, after, _ = API.call("GET", f"staff/loans/{loan_id}") + assert after["data"]["status"] == "restituito", f"loan status {after['data']['status']}" + back_to_hub() + return f"loan #{loan_id} returned by code {code}" + + +@step("copies: add a copy with an explicit code from the book editor") +def copy_with_code(): + if not staff_flag("staff_copies"): + raise SkipStep("staff_copies is off on this instance") + if not CREATED: + raise SkipStep("no book created by this run") + book_id = CREATED[-1] + title = API.book(book_id)["title"] + code = f"E2E-{RUN}-2" + back_to_hub() + ui.scroll_to(S["staff_books"], exact=True) + ui.tap(S["staff_books"], exact=True) + ui.tap(S["staff_books_search"]) + ui.type_text(title) + ui.hide_keyboard() + ui.tap(title, timeout=30) + ui.wait(S["staff_book_save"], 30) + ui.scroll_to(S["staff_copy_add_with_code"]) + ui.tap(S["staff_copy_add_with_code"]) + ui.tap(S["staff_copy_code"]) + ui.type_text(code) + ui.hide_keyboard() + ui.tap(S["action_save"], exact=True) + ui.wait(S["staff_copy_added"], 120) # the copy save runs the full admin pipeline + codes = [c.get("inventory_number") for c in API.book(book_id)["copies"]] + assert code in codes, f"{code} not in {codes}" + back_to_hub() + return f"{code} on book #{book_id}" + + +@step("series: create a series in the app") +def series_roundtrip(): + if not staff_flag("staff_series"): + raise SkipStep("staff_series is off on this instance") + back_to_hub() + ui.scroll_to(S["staff_series"]) + ui.tap(S["staff_series"], exact=True) + ui.tap(S["staff_series_new"]) + ui.tap(S["staff_field_name"]) + ui.type_text(SERIES_NAME) + ui.hide_keyboard() + ui.tap(S["action_save"], exact=True) + ui.wait(S["staff_series_add_books"], 30) + _, rows, _ = API.call("GET", "staff/series?flat=1&q=" + urllib.request.quote(SERIES_NAME)) + found = [r for r in rows.get("data") or [] if r.get("name") == SERIES_NAME] + CREATED_SERIES.extend(r["id"] for r in found) + assert len(found) == 1, f"expected the series once, found {len(found)}" + back_to_hub() + return f"series #{found[0]['id']}" + + +@step("imports: a two-row CSV job runs to completion in the app") +def csv_import_two_rows(): + if not staff_flag("staff_imports"): + raise SkipStep("staff_imports is off on this instance (backend pending)") + csv = "titolo;autore\n" + "\n".join(f"{t};E2E Author" for t in IMPORT_TITLES) + "\n" + status, job, _ = API.call("POST", "staff/imports", multipart={ + "file": (f"e2e-{RUN}.csv", csv.encode(), "text/csv"), "kind": "csv", "enable_scraping": "0"}) + assert status == 201, f"create import HTTP {status}: {job.get('error')}" + back_to_hub() + ui.scroll_to(S["staff_imports"]) + ui.tap(S["staff_imports"]) + ui.scroll_to(f"e2e-{RUN}.csv") + ui.tap(f"e2e-{RUN}.csv") + ui.wait(S["staff_import_status_done"], 120) + for t in IMPORT_TITLES: + rows = [r for r in API.find_book(t) if r["title"] == t] + CREATED.extend(r["id"] for r in rows if r["id"] not in CREATED) + assert len(rows) == 1, f"{t!r} imported {len(rows)} times" + back_to_hub() + return f"job {job['data']['id']}" + + +@step("desiderata: a free offer is received into a picked record from the app") +def desiderata_receive(): + if not staff_flag("staff_desiderata"): + raise SkipStep("staff_desiderata is off on this instance (backend pending)") + target = next((r for r in API.find_book(CIRC_TITLE) if r.get("title") == CIRC_TITLE), None) + if target is None: + raise SkipStep("no record created by this run to receive into") + title = f"E2E offer {RUN}" + # A free offer (no wanted title): the run never touches someone else's wanted list. + status, offer, _ = API.call("POST", "desiderata/offers", {"submission_id": str(uuid.uuid4()), "title": title, "author": "E2E", + "notes": f"E2E {RUN}", "consent": True}) + assert status in (200, 201), f"offer HTTP {status}: {offer.get('error')}" + offer_id = offer["data"]["id"] + CREATED_OFFERS.append(offer_id) + back_to_hub() + ui.scroll_to(S["staff_desiderata"]) + ui.tap(S["staff_desiderata"]) + ui.scroll_to(title) + ui.tap(title) + time.sleep(1.5) # let the sheet finish expanding + ui.tap(S["staff_offer_receive"]) + ui.wait(S["staff_offer_receive_into"], 30) + ui.tap(S["cd_clear"]) + ui.type_text(CIRC_TITLE) + ui.hide_keyboard() + ui.tap(CIRC_TITLE, timeout=60) + ui.wait(S["staff_offer_received"], 120) + _, after, _ = API.call("GET", "staff/desiderata/offers?status=received&q=" + urllib.request.quote(title)) + got = [o for o in after.get("data") or [] if o["id"] == offer_id] + assert got and (got[0].get("received_book") or {}).get("id") == target["id"], f"offer after receive: {got}" + back_to_hub() + return f"offer #{offer_id} received into book #{target['id']}" + + +@step("archives: create an unpublished unit in the app") +def archive_create_delete(): + if not staff_flag("staff_archives"): + raise SkipStep("staff_archives is off on this instance (backend pending)") + back_to_hub() + ui.scroll_to(S["staff_archives"]) + ui.tap(S["staff_archives"], exact=True) + ui.tap(S["staff_archive_new"]) + ui.tap(S["staff_archive_published"]) # new units start published: turn it off + ui.tap(S["staff_archive_reference_code"]) + ui.type_text(ARCHIVE_REF) + ui.hide_keyboard() + ui.tap(S["staff_archive_constructed_title"]) + ui.type_text(f"E2E unit {RUN}") + ui.hide_keyboard() + ui.tap(S["action_save"], exact=True) + ui.wait(S["staff_archive_created"], 30) + _, rows, _ = API.call("GET", "staff/archives?published=all&q=" + urllib.request.quote(ARCHIVE_REF)) + found = [r for r in rows.get("data") or [] if r.get("reference_code") == ARCHIVE_REF] + CREATED_ARCHIVES.extend(r["id"] for r in found) + assert len(found) == 1 and not found[0].get("published"), f"archive rows {found}" + _, public, _ = API.call("GET", "archives?q=" + urllib.request.quote(ARCHIVE_REF)) + assert not any(r.get("reference_code") == ARCHIVE_REF for r in public.get("data") or []), "unpublished unit is public" + back_to_hub() + return f"archive #{found[0]['id']}" + + +@step("periodicals desk: an unknown code is looked up, nothing is changed") +def periodicals_desk(): + if not staff_flag("staff_periodicals"): + raise SkipStep("staff_periodicals is off on this instance") + code = "0000000000017" + _, expected, _ = API.call("GET", "periodicals/staff/scan-lookup?code=" + code) + assert (expected.get("data") or {}).get("match") == "none", f"code {code} unexpectedly matches: {expected.get('data')}" + back_to_hub() + ui.scroll_to(S["staff_periodicals"]) + ui.tap(S["staff_periodicals"], exact=True) + deny_camera_prompt() + ui.tap(S["staff_periodicals_code"]) + ui.type_text(code) + ui.hide_keyboard() + ui.tap(S["staff_scan_lookup"]) + ui.wait(S["staff_periodicals_no_match"], 60) + ui.back() + back_to_hub() + return f"no match for {code}" + + +@step("book clubs: create a club in the app") +def club_create(): + if not staff_flag("staff_bookclub"): + raise SkipStep("staff_bookclub is off on this instance") + back_to_hub() + ui.scroll_to(S["staff_clubs"]) + ui.tap(S["staff_clubs"], exact=True) + ui.tap(S["staff_club_new"]) + ui.tap(S["staff_field_name"]) + ui.type_text(CLUB_NAME) + ui.hide_keyboard() + ui.scroll_to(S["action_save"], exact=True) + ui.tap(S["action_save"], exact=True) + ui.wait(S["staff_club_workflow_edit"], 60) # the new club's page + _, clubs, _ = API.call("GET", "bookclub/staff/clubs") + found = [c for c in clubs.get("data") or [] if c.get("name") == CLUB_NAME] + CREATED_CLUBS.extend(c["id"] for c in found) + assert len(found) == 1 and found[0].get("privacy") == "public", f"clubs named {CLUB_NAME!r}: {found}" + back_to_hub() + return f"club #{found[0]['id']}" + + +@step("FRBR: create a work in the app") +def work_create(): + if not staff_flag("staff_frbr"): + raise SkipStep("staff_frbr is off on this instance") + back_to_hub() + ui.scroll_to(S["staff_works"]) + ui.tap(S["staff_works"], exact=True) + ui.tap(S["staff_work_new"]) + ui.tap(S["staff_work_uniform_title"]) + ui.type_text(WORK_TITLE) + ui.hide_keyboard() + ui.scroll_to(S["action_save"], exact=True) # the work form is taller than the sheet + ui.tap(S["action_save"], exact=True) + ui.wait(S["staff_work_attach"], 60) # the new work's page + _, works, _ = API.call("GET", "staff/works?q=" + urllib.request.quote(WORK_TITLE)) + found = [w for w in works.get("data") or [] if w.get("uniform_title") == WORK_TITLE] + CREATED_WORKS.extend(w["id"] for w in found) + assert len(found) == 1, f"works titled {WORK_TITLE!r}: {found}" + back_to_hub() + return f"work #{found[0]['id']}" + + +# ---------------------------------------------------------------- database (optional) + +DB_FLAGS = ("show_sesso", "show_cod_fiscale") +DB_BEFORE = {} + + +def db_configured(): + return bool(os.environ.get("PINAKES_E2E_DB_NAME") and os.environ.get("PINAKES_E2E_DB_USER")) + + +def sql(query): + """Run one statement with the mysql client; the password goes through MYSQL_PWD, never argv.""" + cmd = ["mysql", "-N", "-B", "-u", os.environ["PINAKES_E2E_DB_USER"]] + if os.environ.get("PINAKES_E2E_DB_SOCKET"): + cmd += ["--socket", os.environ["PINAKES_E2E_DB_SOCKET"]] + else: + cmd += ["-h", os.environ.get("PINAKES_E2E_DB_HOST", "127.0.0.1"), "-P", os.environ.get("PINAKES_E2E_DB_PORT", "3306")] + cmd += [os.environ["PINAKES_E2E_DB_NAME"], "-e", query] + envp = dict(os.environ, MYSQL_PWD=os.environ.get("PINAKES_E2E_DB_PASSWORD", "")) + res = subprocess.run(cmd, capture_output=True, text=True, env=envp) + if res.returncode != 0: + raise RuntimeError("mysql failed: " + res.stderr.strip().splitlines()[-1] if res.stderr.strip() else "mysql failed") + return res.stdout + + +def set_visibility(values): + """values: {flag: "0"/"1"/None}; None deletes the row (back to the default: shown).""" + for flag, value in values.items(): + if value is None: + sql(f"DELETE FROM system_settings WHERE category='registration' AND setting_key='{flag}'") + else: + sql("INSERT INTO system_settings (category, setting_key, setting_value, updated_at) " + f"VALUES ('registration', '{flag}', '{value}', NOW()) " + "ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value), updated_at=NOW()") + + +def wait_health_visibility(expected, timeout=120): + """The server caches settings for up to 60 s: wait until /health reports [expected] + ({flag: bool}).""" + deadline = time.time() + timeout + while time.time() < deadline: + _, body, _ = API.call("GET", "health", auth=False) + reg = (body.get("data") or {}).get("registration") or {} + if all(reg.get(flag) is shown for flag, shown in expected.items()): + return + time.sleep(5) + raise AssertionError(f"/health never reported {expected}") + + +def open_profile_editor(): + open_tab("nav_profile") + ui.wait(S["profile_action_edit"], 90) + ui.tap(S["profile_action_edit"]) + ui.wait(S["profile_first_name"], 30) + + +def editor_has(label): + for _ in range(4): + if ui.find(label, exact=True): + return True + ui.swipe_up() + return False + + +@step("profile: hidden gender / tax code are not shown, then shown again") +def hidden_profile_fields(): + if not db_configured(): + raise SkipStep("PINAKES_E2E_DB_* not set") + rows = sql("SELECT setting_key, setting_value FROM system_settings WHERE category='registration' " + "AND setting_key IN ('show_sesso','show_cod_fiscale')") + DB_BEFORE.update({k: None for k in DB_FLAGS}) + for line in rows.splitlines(): + key, _, value = line.partition("\t") + DB_BEFORE[key] = value + try: + set_visibility({k: "0" for k in DB_FLAGS}) + wait_health_visibility({k: False for k in DB_FLAGS}) + _, me, _ = API.call("GET", "me") + assert set(me["data"].get("hidden_fields") or []) == {"sesso", "cod_fiscale"}, me["data"].get("hidden_fields") + assert "sesso" not in me["data"] and "cod_fiscale" not in me["data"], "hidden keys still in /me" + # Background and foreground the app: Profile re-reads /me and the field schema on resume. + ui.shell("input keyevent KEYCODE_HOME") + time.sleep(1) + ui.shell(f"monkey -p {PKG} -c android.intent.category.LAUNCHER 1") + time.sleep(4) + open_profile_editor() + assert not editor_has(S["profile_tax_code"]), "tax code shown while hidden" + assert not editor_has(S["profile_gender"]), "gender shown while hidden" + ui.back() + finally: + set_visibility(DB_BEFORE) + wait_health_visibility({k: DB_BEFORE.get(k) not in ("0", "false") for k in DB_FLAGS}) + return "hidden, checked, restored" + + +def cleanup(): + print("--> cleanup", flush=True) + try: + _cleanup_server() + except Exception as e: # noqa: BLE001 - the device must be reset even if the server is slow + print(f" server cleanup incomplete: {e} (left: books {CREATED})") + ui.adb("shell", "pm", "clear", PKG, check=False) + + +def _cleanup_server(): + # Records this run created even when a step failed half-way (the ISBN was absent at + # preflight and the manual title is unique to the run). + if API.token: + for q, match in ((ISBN, lambda r: r.get("isbn13") == ISBN or r.get("ean") == ISBN), + (MANUAL_TITLE, lambda r: r.get("title") == MANUAL_TITLE)): + for r in API.find_book(q): + if match(r) and r["id"] not in CREATED: + CREATED.append(r["id"]) + try: + ui.airplane(False) + except Exception: # noqa: BLE001 + pass + if API.token: + # Phase 2 records found by their run-unique names, in case a step failed half-way. + for t in (CIRC_TITLE,) + IMPORT_TITLES: + for r in API.find_book(t): + if r.get("title") == t and r["id"] not in CREATED: + CREATED.append(r["id"]) + for book_id in list(CREATED): + # A loan this run left open keeps the book: return it first. + _, loans, _ = API.call("GET", f"staff/loans?bucket=all&book_id={book_id}") + for loan in loans.get("data") or []: + if "return" in (loan.get("actions") or []): + status, _, _ = API.call("POST", f"staff/loans/{loan['id']}/return", {"outcome": "restituito"}) + print(f" return loan #{loan['id']}: HTTP {status}") + for series_id in CREATED_SERIES: + status, _, _ = API.call("DELETE", f"staff/series/{series_id}") + print(f" delete series #{series_id}: HTTP {status}") + for archive_id in CREATED_ARCHIVES: + status, _, _ = API.call("DELETE", f"staff/archives/{archive_id}") + print(f" delete archive unit #{archive_id}: HTTP {status}") + # Tier 2 records found by their run-unique names, in case a step failed half-way. + _, clubs, _ = API.call("GET", "bookclub/staff/clubs") + CREATED_CLUBS.extend(c["id"] for c in clubs.get("data") or [] if c.get("name") == CLUB_NAME and c["id"] not in CREATED_CLUBS) + _, works, _ = API.call("GET", "staff/works?q=" + urllib.request.quote(WORK_TITLE)) + CREATED_WORKS.extend(w["id"] for w in works.get("data") or [] if w.get("uniform_title") == WORK_TITLE and w["id"] not in CREATED_WORKS) + for club_id in CREATED_CLUBS: + status, _, _ = API.call("DELETE", f"bookclub/staff/clubs/{club_id}") + print(f" delete book club #{club_id}: HTTP {status}") + for work_id in CREATED_WORKS: + status, _, _ = API.call("DELETE", f"staff/works/{work_id}") + print(f" delete work #{work_id}: HTTP {status}") + for offer_id in CREATED_OFFERS: + status, _, _ = API.call("DELETE", f"staff/desiderata/offers/{offer_id}") + print(f" delete offer #{offer_id}: HTTP {status}") + for book_id in CREATED: + status, _, _ = API.call("DELETE", f"staff/books/{book_id}") + print(f" delete book #{book_id}: HTTP {status}") + if API.token: + # Only undo what this run did: a link that existed before the run is left alone. + if STATE["calendar_was_enabled"] is False and STATE.get("calendar_created"): + status, _, _ = API.call("DELETE", "me/calendar/token") + print(f" revoke calendar link created by this run: HTTP {status}") + # Only tokens issued during this run (the app's login); never pre-existing sessions. + if STATE["devices_known"]: + _, devs, _ = API.call("GET", "me/devices") + for d in devs.get("data") or []: + if d["id"] not in DEVICES_BEFORE and not d.get("is_current"): + status, _, _ = API.call("DELETE", f"me/devices/{d['id']}") + print(f" revoke app device token #{d['id']} created by this run: HTTP {status}") + API.call("POST", "auth/logout") + + +def main(): + S.update(load_strings()) + ok = preflight() + try: + # Without a signed-in app the remaining steps cannot run: stop there. + if ok and onboarding_and_login(): + for fn in (staff_hub, scan_add, scan_duplicate, manual_record, edit_location, + circulation_return, copy_with_code, series_roundtrip, csv_import_two_rows, desiderata_receive, archive_create_delete, + periodicals_desk, club_create, work_create, + offline, calendar, hidden_profile_fields): + fn() + finally: + cleanup() + passed = sum(1 for r in RESULTS if r[1] == "PASS") + skipped = sum(1 for r in RESULTS if r[1] == "SKIP") + failed = sum(1 for r in RESULTS if r[1] == "FAIL") + print(f"\nlive smoke: {passed} passed, {skipped} skipped, {failed} failed") + for name, result, detail in RESULTS: + print(f" {result:4} {name} {detail}") + return 1 if failed or not passed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/e2e/ui.py b/tools/e2e/ui.py new file mode 100755 index 0000000..e51acdf --- /dev/null +++ b/tools/e2e/ui.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Small adb + uiautomator driver for the live smoke test (no extra dependencies). + +Usable as a module (``import ui``) or from the shell for poking at a screen: + + ANDROID_SERIAL=emulator-5554 python3 tools/e2e/ui.py dump + python3 tools/e2e/ui.py tap "Admin area" + python3 tools/e2e/ui.py shot /tmp/screen.png + +Texts are matched case-insensitively against a node's text or content description. The +device is chosen by ANDROID_SERIAL (adb's own variable). +""" +import os +import re +import subprocess +import sys +import time +import xml.etree.ElementTree as ET + +SERIAL = os.environ.get("ANDROID_SERIAL", "") +ADB = ["adb"] + (["-s", SERIAL] if SERIAL else []) + + +def adb(*args, check=True, capture=True): + res = subprocess.run(ADB + list(args), capture_output=capture) + if check and res.returncode != 0: + raise RuntimeError(f"adb {' '.join(args[:3])} failed: {res.stderr.decode(errors='replace').strip()}") + return res.stdout + + +def shell(cmd): + return adb("shell", cmd).decode(errors="replace") + + +class Node: + def __init__(self, el): + self.text = el.get("text") or "" + self.desc = el.get("content-desc") or "" + self.res = el.get("resource-id") or "" + self.cls = el.get("class") or "" + self.clickable = el.get("clickable") == "true" + self.checked = el.get("checked") == "true" + b = [int(v) for v in re.findall(r"\d+", el.get("bounds", "[0,0][0,0]"))] + self.bounds = b + self.center = ((b[0] + b[2]) // 2, (b[1] + b[3]) // 2) + + def label(self): + return (self.text + " " + self.desc).strip() + + def __repr__(self): + return f"Node({self.label()!r} {self.bounds})" + + +def dump(): + """All nodes of the current screen (retries: uiautomator dump fails while animating).""" + for _ in range(5): + out = adb("exec-out", "uiautomator", "dump", "/dev/tty", check=False).decode(errors="replace") + start, end = out.find("") + if start >= 0 and end > start: + root = ET.fromstring(out[start:end + len("")]) + return [Node(el) for el in root.iter("node")] + time.sleep(0.5) + raise RuntimeError("uiautomator dump failed") + + +def find(text, exact=False, nodes=None, include_edit=False): + """First node showing [text]. Editable fields are skipped unless [include_edit]: a field + that contains what was just typed must not shadow the list row with the same text.""" + want = text.lower() + for n in nodes if nodes is not None else dump(): + if not include_edit and n.cls == "android.widget.EditText" and n.text: + continue + hay = [n.text.lower(), n.desc.lower()] + if (want in hay) if exact else any(want in h for h in hay if h): + return n + return None + + +def find_res(res_id, nodes=None): + for n in nodes if nodes is not None else dump(): + if n.res == res_id: + return n + return None + + +def wait(text, timeout=20, exact=False): + """Wait until [text] is on screen; returns its node or raises.""" + deadline = time.time() + timeout + while time.time() < deadline: + n = find(text, exact) + if n: + return n + time.sleep(0.7) + raise AssertionError(f"not on screen after {timeout}s: {text!r}") + + +def wait_any(texts, timeout=20): + deadline = time.time() + timeout + while time.time() < deadline: + nodes = dump() + for t in texts: + n = find(t, nodes=nodes) + if n: + return t, n + time.sleep(0.7) + raise AssertionError(f"none on screen after {timeout}s: {texts!r}") + + +def gone(text, timeout=15): + deadline = time.time() + timeout + while time.time() < deadline: + if not find(text): + return True + time.sleep(0.7) + raise AssertionError(f"still on screen after {timeout}s: {text!r}") + + +def tap_xy(x, y): + shell(f"input tap {x} {y}") + time.sleep(0.6) + + +def tap(text, timeout=20, exact=False): + n = wait(text, timeout, exact) + tap_xy(*n.center) + return n + + +_SIZE = None + + +def screen_size(): + global _SIZE + if _SIZE is None: + m = re.search(r"(\d+)x(\d+)", shell("wm size").splitlines()[-1]) + _SIZE = (int(m.group(1)), int(m.group(2))) if m else (1080, 2400) + return _SIZE + + +def swipe_up(): + w, h = screen_size() + shell(f"input swipe {w // 2} {int(h * 0.75)} {w // 2} {int(h * 0.30)} 300") + time.sleep(0.6) + + +def swipe_down(): + w, h = screen_size() + shell(f"input swipe {w // 2} {int(h * 0.30)} {w // 2} {int(h * 0.75)} 300") + time.sleep(0.6) + + +def scroll_to(text, max_swipes=8, exact=False, passes=2): + """Scroll down looking for [text], then back up (content can appear late), [passes] times.""" + for p in range(passes): + for _ in range(max_swipes): + n = find(text, exact) + if n: + return n + (swipe_up if p % 2 == 0 else swipe_down)() + raise AssertionError(f"not found while scrolling: {text!r}") + + +def _escape(value): + # `input text` goes through the device shell: escape every non-alphanumeric character. + out = [] + for ch in value: + if ch == " ": + out.append("%s") + elif ch.isalnum(): + out.append(ch) + else: + out.append("\\" + ch) + return "".join(out) + + +def type_text(value, secret=False): + """Type into the focused field. With secret=True nothing is ever echoed.""" + adb("shell", "input text " + _escape(value), check=True) + time.sleep(0.4) + + +def fill(label, value, secret=False): + tap(label) + shell("input keyevent KEYCODE_MOVE_END") + type_text(value, secret=secret) + hide_keyboard() + + +def keyboard_shown(): + """The IME insets source is visible (more reliable than input_method's mInputShown).""" + for line in shell("dumpsys window").splitlines(): + if "InsetsSource" in line and "type=ime" in line: + return "visible=true" in line + return "mInputShown=true" in shell("dumpsys input_method") + + +def hide_keyboard(): + """Close the soft keyboard (BACK only when it is really shown, or BACK would navigate).""" + if keyboard_shown(): + shell("input keyevent KEYCODE_BACK") + time.sleep(0.6) + + +def back(): + shell("input keyevent KEYCODE_BACK") + time.sleep(0.6) + + +def airplane(on): + shell(f"cmd connectivity airplane-mode {'enable' if on else 'disable'}") + time.sleep(3) + + +def screenshot(path): + with open(path, "wb") as fh: + fh.write(adb("exec-out", "screencap", "-p")) + return path + + +def main(argv): + cmd = argv[1] if len(argv) > 1 else "dump" + if cmd == "dump": + for n in dump(): + if n.label(): + print(n.label(), n.bounds, "click" if n.clickable else "") + elif cmd in ("tap", "find"): + n = (tap if cmd == "tap" else wait)(argv[2]) + print("match", n) + elif cmd == "shot": + print(screenshot(argv[2])) + else: + print(__doc__) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))