Wave 4 (partial): live-sync harness + Library screenshots; fix authors null decode

Both wave-4 workers hit the 5h session limit ~11 minutes in and were then lost
to a sprite suspend. This commit preserves the work that landed before that,
independently verified green (assembleDebug + testDebugUnitTest, 94 tests).

F1-livesync:
- LiveSyncTest + server/live-sync-test.sh: end-to-end exercise against a real
  PocketBase (auth, push with client ids, pull, tombstones, cover round-trip).
  Gated behind LIVE_SYNC=1 so the normal test task stays green with no server.
- Fix: BookDto.authors must be nullable. PocketBase serializes an unset `json`
  field as literal null (unlike text/number, which come back ""/0), so decoding
  any real response with empty authors threw. Found by the live test; no fake
  had ever reproduced it.
- Fix: cover upload derived its media type from the filename instead of
  hardcoding image/jpeg.

F2-release:
- ScreenFixtures + LibraryScreenPaparazziTest: library populated and empty,
  light and dark (4 PNGs).

Still owed by wave 4: screenshots for the other five screens, release keystore
+ signed APK, top-level README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016mTs3kQXQsQwonXpEq7aEw
This commit is contained in:
2026-09-06 18:56:55 +00:00
parent 0088fda095
commit cf36b27457
18 changed files with 682 additions and 21 deletions
@@ -18,7 +18,12 @@ data class BookDto(
val id: String = "",
val title: String = "",
val subtitle: String = "",
val authors: List<String> = emptyList(),
// Nullable: PocketBase's `authors` field is type `json`, and its unset/zero
// value serializes as a raw JSON `null` (unlike text/number fields, which
// come back as "" / 0) -- a non-nullable List here throws decoding a real
// server response the moment authors is empty. Discovered via the wave 4
// live-server sync test (org.modg.bookshelf.livesync.LiveSyncTest).
val authors: List<String>? = emptyList(),
val isbn13: String = "",
val isbn10: String = "",
val publisher: String = "",
@@ -171,7 +171,7 @@ class SyncEngine(
return
}
try {
val body = file.asRequestBody("image/jpeg".toMediaTypeOrNull())
val body = file.asRequestBody(guessImageMediaType(file.name))
val part = MultipartBody.Part.createFormData("cover", file.name, body)
val response = api.uploadBookCover(synced.id, part)
val baseUrl = settingsStore.serverUrl.first()
@@ -291,6 +291,25 @@ class SyncEngine(
}
}
/**
* PocketBase's `cover` field is a generic image-mimes file field; it does not
* infer the multipart part's Content-Type from the actual bytes, only from
* whatever we declare here. [BookRepository]'s downloader always writes
* `.jpg` today, but [BookEntity.localCoverPath] is a plain file path with no
* other type guarantee, so this derives the media type from the extension
* instead of hardcoding `image/jpeg` — a mismatch would silently mislabel
* (and, on stricter servers, could get the upload rejected).
*/
private fun guessImageMediaType(fileName: String): okhttp3.MediaType? {
val type = when (fileName.substringAfterLast('.', "").lowercase()) {
"png" -> "image/png"
"webp" -> "image/webp"
"gif" -> "image/gif"
else -> "image/jpeg"
}
return type.toMediaTypeOrNull()
}
// ---------------------------------------------------------------- mapping
private fun BookEntity.toDto(): BookDto = BookDto(
@@ -318,7 +337,7 @@ private fun BookDto.toEntity(updatedMillis: Long, localCoverPath: String?, cover
id = id,
title = title,
subtitle = subtitle.ifBlank { null },
authorsJson = encodeAuthors(authors),
authorsJson = encodeAuthors(authors.orEmpty()),
isbn13 = isbn13.ifBlank { null },
isbn10 = isbn10.ifBlank { null },
publisher = publisher.ifBlank { null },
@@ -175,7 +175,7 @@ fun DetailScreen(
}
@Composable
private fun BookHeader(book: BookEntity) {
internal fun BookHeader(book: BookEntity) {
Row(modifier = Modifier.fillMaxWidth()) {
BookCover(
coverUrl = book.coverUrl ?: book.coverSourceUrl,
@@ -222,7 +222,7 @@ private fun InfoLine(label: String, value: String?) {
}
@Composable
private fun DescriptionSection(description: String?) {
internal fun DescriptionSection(description: String?) {
if (description.isNullOrBlank()) return
var expanded by remember { mutableStateOf(false) }
Column {
@@ -245,7 +245,7 @@ private fun DescriptionSection(description: String?) {
}
@Composable
private fun NotesSection(book: BookEntity, onSave: (String) -> Unit) {
internal fun NotesSection(book: BookEntity, onSave: (String) -> Unit) {
var notes by remember(book.id) { mutableStateOf(book.notes.orEmpty()) }
Column {
Text(text = "Notes", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -267,7 +267,7 @@ private fun NotesSection(book: BookEntity, onSave: (String) -> Unit) {
}
@Composable
private fun LocationSection(
internal fun LocationSection(
book: BookEntity,
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
@@ -160,7 +160,7 @@ fun LibraryScreen(
}
@Composable
private fun LibraryToolbar(
internal fun LibraryToolbar(
query: String,
onQueryChange: (String) -> Unit,
sortOption: LibrarySortOption,
@@ -257,7 +257,7 @@ private fun LibraryToolbar(
}
@Composable
private fun LibraryBookCard(book: BookEntity, onClick: () -> Unit) {
internal fun LibraryBookCard(book: BookEntity, onClick: () -> Unit) {
Column(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
BookCover(
coverUrl = book.coverUrl ?: book.coverSourceUrl,
@@ -166,7 +166,7 @@ fun LocationsScreen(
}
@Composable
private fun BookcaseRow(
internal fun BookcaseRow(
bookcaseUi: BookcaseUi,
onEdit: () -> Unit,
onDelete: () -> Unit,
@@ -220,7 +220,7 @@ private fun BookcaseRow(
}
@Composable
private fun ShelfRow(
internal fun ShelfRow(
shelfUi: ShelfUi,
onClick: () -> Unit,
onEdit: () -> Unit,
@@ -252,7 +252,7 @@ private fun ShelfRow(
}
@Composable
private fun ReorderButtons(onMoveUp: () -> Unit, onMoveDown: () -> Unit) {
internal fun ReorderButtons(onMoveUp: () -> Unit, onMoveDown: () -> Unit) {
Row {
IconButton(onClick = onMoveUp) { Icon(Icons.Filled.ArrowDropUp, contentDescription = "Move up") }
IconButton(onClick = onMoveDown) { Icon(Icons.Filled.ArrowDropDown, contentDescription = "Move down") }
@@ -222,7 +222,7 @@ private fun CameraPreview(controller: ScannerController, torchEnabled: Boolean)
}
@Composable
private fun ScanReticle(modifier: Modifier = Modifier) {
internal fun ScanReticle(modifier: Modifier = Modifier) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
@@ -239,7 +239,7 @@ private fun ScanReticle(modifier: Modifier = Modifier) {
}
@Composable
private fun SessionBadge(count: Int, modifier: Modifier = Modifier) {
internal fun SessionBadge(count: Int, modifier: Modifier = Modifier) {
if (count == 0) return
Text(
text = "Added this session: $count",
@@ -252,7 +252,7 @@ private fun SessionBadge(count: Int, modifier: Modifier = Modifier) {
}
@Composable
private fun PermissionDeniedContent(onRequestAgain: () -> Unit) {
internal fun PermissionDeniedContent(onRequestAgain: () -> Unit) {
EmptyState(
title = "Camera access needed",
message = "Bookshelf needs the camera to scan barcodes. Grant permission to continue.",
@@ -261,7 +261,7 @@ private fun PermissionDeniedContent(onRequestAgain: () -> Unit) {
}
@Composable
private fun FoundBookSheet(
internal fun FoundBookSheet(
metadata: BookMetadata,
duplicate: DuplicateStatus,
bookcases: List<BookcaseEntity>,
@@ -309,7 +309,7 @@ private fun FoundBookSheet(
}
@Composable
private fun ManualEntrySheet(
internal fun ManualEntrySheet(
isbn13: String,
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
@@ -362,7 +362,7 @@ private fun ManualEntrySheet(
}
@Composable
private fun ShelfPicker(
internal fun ShelfPicker(
bookcases: List<BookcaseEntity>,
shelves: List<ShelfEntity>,
selectedShelfId: String?,
@@ -120,7 +120,7 @@ fun SettingsScreen(
}
@Composable
private fun SectionHeading(text: String) {
internal fun SectionHeading(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
@@ -129,7 +129,7 @@ private fun SectionHeading(text: String) {
}
@Composable
private fun InfoRow(label: String, value: String) {
internal fun InfoRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -146,7 +146,7 @@ private fun syncLabel(state: SettingsUiState): String = when {
else -> "Not synced yet"
}
private fun formatLastSync(epochMillis: Long?): String {
internal fun formatLastSync(epochMillis: Long?): String {
if (epochMillis == null) return "Never"
val elapsed = System.currentTimeMillis() - epochMillis
return when {
@@ -0,0 +1,324 @@
package org.modg.bookshelf.livesync
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.flow.first
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.BookshelfDatabase
import org.modg.bookshelf.data.local.IdGenerator
import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.local.SyncState
import org.modg.bookshelf.data.prefs.SettingsStore
import org.modg.bookshelf.data.remote.PbAuthInterceptor
import org.modg.bookshelf.data.remote.PocketBaseApi
import org.modg.bookshelf.data.repo.AuthRepository
import org.modg.bookshelf.data.repo.SyncEngine
import org.modg.bookshelf.data.repo.SyncResult
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import retrofit2.Retrofit
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.zip.CRC32
import java.util.zip.Deflater
/**
* Wave 4 (F1): proves the sync + cover pipeline against a REAL PocketBase,
* not the fakes every other test in this repo uses. See docs/HANDOFF.md,
* "known gaps carried into wave 4" #1 — this closes that gap.
*
* NEVER runs as part of the default `testDebugUnitTest` suite: gated behind
* the `LIVE_SYNC` environment variable via [assumeTrue], so machines with no
* PocketBase running stay green (JUnit reports this test as SKIPPED, not
* failed or passed). Invoke it explicitly with `server/live-sync-test.sh`,
* which starts from a throwaway app user and sets the env vars below.
*
* Deliberately does NOT go through [PocketBaseApi]/Retrofit for its own
* assertions (only for driving [SyncEngine] itself, exactly like production
* code would) — the read-back/mutate/download helpers at the bottom hit the
* documented REST endpoints directly with a plain OkHttpClient, so this is
* really checking the wire contract and not just replaying our own client
* code back at itself.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class LiveSyncTest {
private val baseUrl = System.getenv("LIVE_SYNC_BASE_URL") ?: "http://127.0.0.1:8090"
private val email = System.getenv("LIVE_SYNC_EMAIL") ?: "livetest@example.com"
private val password = System.getenv("LIVE_SYNC_PASSWORD") ?: "livetest-passw0rd-1"
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = false; explicitNulls = false }
private val plainHttp = OkHttpClient()
private lateinit var db: BookshelfDatabase
private lateinit var settingsStore: SettingsStore
private lateinit var api: PocketBaseApi
private lateinit var authRepository: AuthRepository
private lateinit var engine: SyncEngine
private var token: String = ""
@Before
fun setUp() {
assumeTrue(
"Live PocketBase test skipped (set LIVE_SYNC=1; see server/live-sync-test.sh)",
System.getenv("LIVE_SYNC") != null,
)
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java)
.allowMainThreadQueries()
.build()
settingsStore = SettingsStore(context)
runTest { settingsStore.setServerUrl(baseUrl) }
// Mirrors AppContainer's real wiring (Retrofit + PbAuthInterceptor reading
// a token supplier) rather than inventing a parallel client shape.
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(PbAuthInterceptor { token.ifBlank { null } })
.build()
api = Retrofit.Builder()
.baseUrl("$baseUrl/")
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(PocketBaseApi::class.java)
authRepository = AuthRepository({ api }, settingsStore)
engine = SyncEngine(
apiProvider = { api },
bookDao = db.bookDao(),
bookcaseDao = db.bookcaseDao(),
shelfDao = db.shelfDao(),
settingsStore = settingsStore,
)
}
@After
fun tearDown() {
if (::db.isInitialized) db.close()
}
@Test
fun `end to end sync and cover round trip against a live PocketBase`() = runTest {
// ---- 1. AUTH: login as the throwaway test user, token feeds PbAuthInterceptor
val loginResult = authRepository.login(email, password)
assertTrue("login failed: ${loginResult.exceptionOrNull()}", loginResult.isSuccess)
token = settingsStore.authToken.first().orEmpty()
assertTrue("no auth token persisted after login", token.isNotBlank())
// ---- 2. PUSH: bookcase -> shelf -> book, client-generated 15-char ids,
// book carries a cover from the very first create push (also covers #5).
val bookcaseId = IdGenerator.newId()
val shelfId = IdGenerator.newId()
val bookId = IdGenerator.newId()
val now = System.currentTimeMillis()
db.bookcaseDao().upsert(
BookcaseEntity(id = bookcaseId, name = "Live Sync Case", createdAt = now, updatedAt = now, syncState = SyncState.PENDING_CREATE),
)
db.shelfDao().upsert(
ShelfEntity(id = shelfId, bookcaseId = bookcaseId, label = "Top Shelf", createdAt = now, updatedAt = now, syncState = SyncState.PENDING_CREATE),
)
val coverBytes = tinyPng()
val coverFile = File.createTempFile("livesync-cover", ".png").apply { writeBytes(coverBytes) }
db.bookDao().upsert(
BookEntity(
id = bookId, title = "Live Sync Test Book", shelfId = shelfId,
createdAt = now, updatedAt = now, syncState = SyncState.PENDING_CREATE,
localCoverPath = coverFile.absolutePath,
),
)
val firstSync = engine.sync()
assertTrue("first sync (push) failed: $firstSync", firstSync is SyncResult.Success)
// Ids preserved and NOT remapped -- verified by reading back through the
// raw REST API, independent of our own DAOs/Retrofit client.
val remoteBookcase = getRecord("bookcases", bookcaseId)
val remoteShelf = getRecord("shelves", shelfId)
var remoteBook = getRecord("books", bookId)
assertEquals(bookcaseId, remoteBookcase["id"]?.jsonPrimitive?.content)
assertEquals(shelfId, remoteShelf["id"]?.jsonPrimitive?.content)
assertEquals(bookId, remoteBook["id"]?.jsonPrimitive?.content)
assertEquals(bookcaseId, remoteShelf["bookcase"]?.jsonPrimitive?.content)
assertEquals(shelfId, remoteBook["shelf"]?.jsonPrimitive?.content)
// ---- 5. COVER ROUND-TRIP: fetch the uploaded file back and compare bytes.
val coverFilename = remoteBook["cover"]?.jsonPrimitive?.content.orEmpty()
assertTrue("server record has no cover filename -- upload did not land", coverFilename.isNotBlank())
val downloadedCover = getFileBytes("books", bookId, coverFilename)
assertArrayEquals("downloaded cover bytes must match what was uploaded", coverBytes, downloadedCover)
val localAfterFirstSync = db.bookDao().getById(bookId)
assertEquals(SyncState.SYNCED, localAfterFirstSync?.syncState)
assertNull("localCoverPath must be cleared once the upload succeeds", localAfterFirstSync?.localCoverPath)
assertTrue(
"coverUrl should point at the uploaded file",
localAfterFirstSync?.coverUrl?.endsWith("/api/files/books/$bookId/$coverFilename") == true,
)
// ---- 3. PULL + last-write-wins: mutate server-side (as if another
// device edited it), then pull and confirm the newer remote copy wins.
Thread.sleep(1100) // PocketBase `updated` has millisecond precision; force a distinct value.
rawPatch("books", bookId, """{"title":"Server-Side Edit"}""")
val secondSync = engine.sync()
assertTrue("second sync (pull) failed: $secondSync", secondSync is SyncResult.Success)
val afterPull = db.bookDao().getById(bookId)
assertEquals("LWW should have pulled the server-side edit", "Server-Side Edit", afterPull?.title)
// ---- 6. CURSOR: a second, immediate pull is a no-op -- no duplicate rows,
// cursor does not regress.
val cursorAfterPull = settingsStore.cursorFor(SettingsStore.COLLECTION_BOOKS).first()
val thirdSync = engine.sync()
assertTrue("third sync failed: $thirdSync", thirdSync is SyncResult.Success)
val cursorAfterNoOpPull = settingsStore.cursorFor(SettingsStore.COLLECTION_BOOKS).first()
assertEquals("cursor must not move on a no-op pull", cursorAfterPull, cursorAfterNoOpPull)
val stillOne = db.bookDao().getById(bookId)
assertEquals("no duplicate/second row for the same id", "Server-Side Edit", stillOne?.title)
// ---- 4. TOMBSTONE: soft-delete locally, push, confirm deleted=true
// propagates and the record is filtered out of local (non-tombstone) queries.
val toDelete = db.bookDao().getById(bookId)!!
db.bookDao().upsert(toDelete.copy(deleted = true, syncState = SyncState.PENDING_DELETE, updatedAt = System.currentTimeMillis()))
val deleteSync = engine.sync()
assertTrue("delete sync failed: $deleteSync", deleteSync is SyncResult.Success)
remoteBook = getRecord("books", bookId)
assertEquals(true, remoteBook["deleted"]?.jsonPrimitive?.boolean)
assertNull("soft-deleted record must be filtered from normal local reads", db.bookDao().getById(bookId))
assertNotNull("tombstone row must still exist locally for sync bookkeeping", db.bookDao().getByIdIncludingDeleted(bookId))
// Best-effort cleanup so repeated runs don't accumulate junk on the server.
rawDelete("books", bookId)
rawDelete("shelves", shelfId)
rawDelete("bookcases", bookcaseId)
}
// ------------------------------------------------------------------
// Raw REST helpers -- intentionally bypass PocketBaseApi/Retrofit so the
// assertions above check the actual documented HTTP contract.
// ------------------------------------------------------------------
private fun getRecord(collection: String, id: String): kotlinx.serialization.json.JsonObject {
val request = Request.Builder()
.url("$baseUrl/api/collections/$collection/records/$id")
.header("Authorization", token)
.build()
plainHttp.newCall(request).execute().use { resp ->
val bodyText = resp.body.string()
assertTrue("GET $collection/$id failed: HTTP ${resp.code} $bodyText", resp.isSuccessful)
return json.parseToJsonElement(bodyText).jsonObject
}
}
private fun rawPatch(collection: String, id: String, jsonBody: String) {
val body = jsonBody.toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$baseUrl/api/collections/$collection/records/$id")
.header("Authorization", token)
.patch(body)
.build()
plainHttp.newCall(request).execute().use { resp ->
val bodyText = resp.body.string()
assertTrue("PATCH $collection/$id failed: HTTP ${resp.code} $bodyText", resp.isSuccessful)
}
}
private fun rawDelete(collection: String, id: String) {
val request = Request.Builder()
.url("$baseUrl/api/collections/$collection/records/$id")
.header("Authorization", token)
.delete()
.build()
runCatching { plainHttp.newCall(request).execute().close() }
}
private fun getFileBytes(collection: String, id: String, filename: String): ByteArray {
val request = Request.Builder()
.url("$baseUrl/api/files/$collection/$id/$filename")
.header("Authorization", token)
.build()
plainHttp.newCall(request).execute().use { resp ->
assertTrue("file download failed: HTTP ${resp.code}", resp.isSuccessful)
return resp.body.bytes()
}
}
/**
* Hand-assembled minimal valid 1x1 PNG (no ImageIO/asset/network dependency
* per the task -- must not depend on openlibrary.org or any external
* service being reachable). Single opaque red pixel.
*/
private fun tinyPng(): ByteArray {
fun chunk(type: String, data: ByteArray): ByteArray {
val typeAndData = type.toByteArray(Charsets.US_ASCII) + data
val crc = CRC32().apply { update(typeAndData) }.value
val out = ByteArrayOutputStream()
out.write(intToBytes(data.size))
out.write(typeAndData)
out.write(intToBytes(crc.toInt()))
return out.toByteArray()
}
val ihdr = ByteArrayOutputStream().apply {
write(intToBytes(1)) // width
write(intToBytes(1)) // height
write(8) // bit depth
write(2) // color type: truecolor (RGB)
write(0) // compression
write(0) // filter
write(0) // interlace
}.toByteArray()
// One scanline: filter-type byte (0 = none) + one RGB pixel (opaque red).
val raw = byteArrayOf(0, 0xFF.toByte(), 0x00, 0x00)
val compressed = ByteArrayOutputStream().also { out ->
val deflater = Deflater()
deflater.setInput(raw)
deflater.finish()
val buf = ByteArray(64)
while (!deflater.finished()) {
val n = deflater.deflate(buf)
out.write(buf, 0, n)
}
}.toByteArray()
val signature = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)
val out = ByteArrayOutputStream()
out.write(signature)
out.write(chunk("IHDR", ihdr))
out.write(chunk("IDAT", compressed))
out.write(chunk("IEND", ByteArray(0)))
return out.toByteArray()
}
private fun intToBytes(value: Int): ByteArray = byteArrayOf(
(value ushr 24).toByte(),
(value ushr 16).toByte(),
(value ushr 8).toByte(),
value.toByte(),
)
}
@@ -0,0 +1,116 @@
package org.modg.bookshelf.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.QrCodeScanner
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Warehouse
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import org.junit.Rule
import org.junit.Test
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.ui.components.BookshelfScaffold
import org.modg.bookshelf.ui.components.EmptyState
import org.modg.bookshelf.ui.components.PaperSurface
import org.modg.bookshelf.ui.components.PrimaryButton
import org.modg.bookshelf.ui.components.SyncStatus
import org.modg.bookshelf.ui.components.SyncStatusBar
import org.modg.bookshelf.ui.library.LibraryBookCard
import org.modg.bookshelf.ui.library.LibraryFilter
import org.modg.bookshelf.ui.library.LibraryScreen
import org.modg.bookshelf.ui.library.LibrarySortOption
import org.modg.bookshelf.ui.library.LibraryToolbar
import org.modg.bookshelf.ui.theme.BookshelfTheme
/**
* SPEC.md "library" screen — cover grid, search/filter/sort toolbar, sync
* status line, empty state. Real screen shell (see [LibraryScreen]) rebuilt
* here around the real [LibraryToolbar]/[LibraryBookCard] with static fixture
* data — see [ScreenFixtures] for why a real [org.modg.bookshelf.AppContainer]
* can't be used inside Paparazzi.
*/
@OptIn(ExperimentalMaterial3Api::class)
class LibraryScreenPaparazziTest {
@get:Rule
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
@Test
fun libraryPopulatedLight() = snapshotBoth("library-populated") { Populated() }
@Test
fun libraryEmptyLight() = snapshotBoth("library-empty") { Empty() }
@Composable
private fun Populated() = Shell(books = ScreenFixtures.books, syncStatus = SyncStatus.Synced, syncLabel = "Synced • 2m ago")
@Composable
private fun Empty() = Shell(books = emptyList(), syncStatus = SyncStatus.Offline, syncLabel = "Not synced yet")
@Composable
private fun Shell(books: List<BookEntity>, syncStatus: SyncStatus, syncLabel: String) {
BookshelfScaffold(
title = "Bookshelf",
actions = {
IconButton(onClick = {}) { Icon(Icons.Outlined.Warehouse, contentDescription = "Bookcases & shelves") }
IconButton(onClick = {}) { Icon(Icons.Outlined.Settings, contentDescription = "Settings") }
},
floatingActionButton = {
FloatingActionButton(onClick = {}) { Icon(Icons.Outlined.QrCodeScanner, contentDescription = "Scan a book") }
},
syncStatusBar = { SyncStatusBar(status = syncStatus, label = syncLabel) },
) { innerPadding ->
PaperSurface(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize()) {
LibraryToolbar(
query = "",
onQueryChange = {},
sortOption = LibrarySortOption.TITLE,
onSortOptionChange = {},
filter = LibraryFilter.All,
onFilterChange = {},
bookcases = ScreenFixtures.bookcases,
shelves = ScreenFixtures.shelves,
)
if (books.isEmpty()) {
EmptyState(
title = "Your shelves are empty",
message = "Scan a barcode to add your first book.",
action = { PrimaryButton(text = "Scan a book", onClick = {}) },
)
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 110.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(innerPadding).fillMaxSize(),
) {
items(books, key = { it.id }) { book -> LibraryBookCard(book = book, onClick = {}) }
}
}
}
}
}
}
private fun snapshotBoth(name: String, content: @Composable () -> Unit) {
paparazzi.snapshot(name = "$name-light") { BookshelfTheme(darkTheme = false) { content() } }
paparazzi.snapshot(name = "$name-dark") { BookshelfTheme(darkTheme = true) { content() } }
}
}
@@ -0,0 +1,124 @@
package org.modg.bookshelf.ui.screens
import org.modg.bookshelf.data.local.BookEntity
import org.modg.bookshelf.data.local.BookcaseEntity
import org.modg.bookshelf.data.local.ShelfEntity
import org.modg.bookshelf.data.local.SyncState
import org.modg.bookshelf.data.repo.encodeAuthors
/**
* Static fixture data shared by the wave-4 (F2) screen Paparazzi tests.
*
* Why static fixtures instead of driving the real screens through
* [org.modg.bookshelf.AppContainer]: every screen's ViewModel reaches Room
* (via a repository) and/or builds an OkHttpClient (SetupViewModel's
* reachability probe), and both blow up inside Paparazzi's headless
* rendering sandbox — confirmed empirically before writing these tests:
* - Room: any real query throws `NullPointerException` out of
* `SQLiteConnection.setJournalMode` — the sandbox has no native SQLite.
* - OkHttpClient: constructing one throws `NoClassDefFoundError` on
* `com.android.org.conscrypt.TrustManagerImpl` — no real Android platform
* TLS stack on the host JVM.
* Neither is a production bug; both are artifacts of Paparazzi's JVM-only
* environment. So these tests render the screens' actual presentational
* composables (the ones each screen file exposes at `internal` visibility
* for this purpose — no behavior changed, only widened visibility) fed with
* plain in-memory data, and rebuild each screen's thin Scaffold/layout shell
* inline. See docs/BUILD_NOTES.md and the wave-4 report for the full story.
*/
object ScreenFixtures {
val livingRoom = BookcaseEntity(
id = "bc-living-room",
name = "Living Room",
note = "Built-ins by the window",
position = 0,
createdAt = 1,
updatedAt = 1,
syncState = SyncState.SYNCED,
)
val study = BookcaseEntity(
id = "bc-study",
name = "Study",
note = null,
position = 1,
createdAt = 1,
updatedAt = 1,
syncState = SyncState.SYNCED,
)
val topShelf = ShelfEntity(id = "sh-top", bookcaseId = livingRoom.id, label = "Top shelf — fiction", position = 0, createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED)
val bottomShelf = ShelfEntity(id = "sh-bottom", bookcaseId = livingRoom.id, label = "Bottom shelf — reference", position = 1, createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED)
val deskShelf = ShelfEntity(id = "sh-desk", bookcaseId = study.id, label = "Desk shelf", position = 0, createdAt = 1, updatedAt = 1, syncState = SyncState.SYNCED)
val bookcases = listOf(livingRoom, study)
val shelves = listOf(topShelf, bottomShelf, deskShelf)
private fun book(
id: String,
title: String,
authors: List<String>,
subtitle: String? = null,
isbn13: String? = null,
publisher: String? = null,
publishedDate: String? = null,
pageCount: Int? = null,
description: String? = null,
shelfId: String? = null,
notes: String? = null,
createdAt: Long = 1,
) = BookEntity(
id = id,
title = title,
subtitle = subtitle,
authorsJson = encodeAuthors(authors),
isbn13 = isbn13,
isbn10 = null,
publisher = publisher,
publishedDate = publishedDate,
pageCount = pageCount,
description = description,
coverUrl = null, // placeholder art only — see task instructions, no network in Paparazzi anyway
coverSourceUrl = null,
shelfId = shelfId,
notes = notes,
addedBy = null,
createdAt = createdAt,
updatedAt = createdAt,
syncState = SyncState.SYNCED,
)
/** A plausible shelf of a two-person household's books, spread across both bookcases. */
val books = listOf(
book("b1", "Dune", listOf("Frank Herbert"), isbn13 = "9780441013593", publisher = "Ace", publishedDate = "1965", pageCount = 412, shelfId = topShelf.id),
book("b2", "The Hobbit", listOf("J.R.R. Tolkien"), isbn13 = "9780547928227", publisher = "Houghton Mifflin", publishedDate = "1937", pageCount = 310, shelfId = topShelf.id),
book("b3", "A Wizard of Earthsea", listOf("Ursula K. Le Guin"), isbn13 = "9780547773742", publisher = "Parnassus Press", publishedDate = "1968", pageCount = 183, shelfId = topShelf.id),
book("b4", "Piranesi", listOf("Susanna Clarke"), isbn13 = "9781635575637", publisher = "Bloomsbury", publishedDate = "2020", pageCount = 245, shelfId = topShelf.id),
book("b5", "The Left Hand of Darkness", listOf("Ursula K. Le Guin"), isbn13 = "9780441478125", publisher = "Ace", publishedDate = "1969", pageCount = 304, shelfId = bottomShelf.id),
book("b6", "On Cooking", listOf("Sarah R. Labensky", "Alan M. Hause"), publisher = "Pearson", publishedDate = "2017", pageCount = 1200, shelfId = bottomShelf.id),
book("b7", "The Way of Kings", listOf("Brandon Sanderson"), subtitle = "The Stormlight Archive, Book One", isbn13 = "9780765326355", publisher = "Tor", publishedDate = "2010", pageCount = 1007, shelfId = deskShelf.id),
book("b8", "Klara and the Sun", listOf("Kazuo Ishiguro"), isbn13 = "9780571364879", publisher = "Faber & Faber", publishedDate = "2021", pageCount = 303, shelfId = deskShelf.id),
book("b9", "Project Hail Mary", listOf("Andy Weir"), isbn13 = "9780593135204", publisher = "Ballantine", publishedDate = "2021", pageCount = 476, shelfId = deskShelf.id),
book("b10", "Circe", listOf("Madeline Miller"), isbn13 = "9780316556347", publisher = "Little, Brown", publishedDate = "2018", pageCount = 393, shelfId = null),
book("b11", "The Fifth Season", listOf("N.K. Jemisin"), subtitle = "The Broken Earth, Book One", isbn13 = "9780316229296", publisher = "Orbit", publishedDate = "2015", pageCount = 468, shelfId = null),
book("b12", "Howl's Moving Castle", listOf("Diana Wynne Jones"), isbn13 = "9780064410670", publisher = "Greenwillow Books", publishedDate = "1986", pageCount = 329, shelfId = topShelf.id),
)
/** The book shown on the detail screen — has notes, a description, and a shelf, so every section renders. */
val detailBook = book(
id = "b-detail",
title = "Piranesi",
authors = listOf("Susanna Clarke"),
isbn13 = "9781635575637",
publisher = "Bloomsbury Publishing",
publishedDate = "September 15, 2020",
pageCount = 245,
description = "Piranesi lives in the House. Perhaps he always has. In his notebooks, he " +
"makes a clear and careful record of its wonders: the labyrinth of halls, the thousands upon " +
"thousands of statues, the tides that thunder up staircases, the clouds that move in slow " +
"procession through the upper halls. On Tuesdays and Fridays Piranesi sees the Other, who needs " +
"help with research into A Great and Secret Knowledge.",
shelfId = topShelf.id,
notes = "First edition, gift from Mom. Reread every autumn.",
)
}