diff --git a/app/app/src/main/java/org/modg/bookshelf/data/remote/Dtos.kt b/app/app/src/main/java/org/modg/bookshelf/data/remote/Dtos.kt index 4e71d23..489ac3e 100644 --- a/app/app/src/main/java/org/modg/bookshelf/data/remote/Dtos.kt +++ b/app/app/src/main/java/org/modg/bookshelf/data/remote/Dtos.kt @@ -18,7 +18,12 @@ data class BookDto( val id: String = "", val title: String = "", val subtitle: String = "", - val authors: List = 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? = emptyList(), val isbn13: String = "", val isbn10: String = "", val publisher: String = "", diff --git a/app/app/src/main/java/org/modg/bookshelf/data/repo/SyncEngine.kt b/app/app/src/main/java/org/modg/bookshelf/data/repo/SyncEngine.kt index 30df4b8..c71fa81 100644 --- a/app/app/src/main/java/org/modg/bookshelf/data/repo/SyncEngine.kt +++ b/app/app/src/main/java/org/modg/bookshelf/data/repo/SyncEngine.kt @@ -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 }, diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/detail/DetailScreen.kt b/app/app/src/main/java/org/modg/bookshelf/ui/detail/DetailScreen.kt index 449287d..99f5bc6 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/detail/DetailScreen.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/detail/DetailScreen.kt @@ -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, shelves: List, diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryScreen.kt b/app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryScreen.kt index 25148a5..0a77d91 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryScreen.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/library/LibraryScreen.kt @@ -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, diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/locations/LocationsScreen.kt b/app/app/src/main/java/org/modg/bookshelf/ui/locations/LocationsScreen.kt index 8a6acd3..a1f2e17 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/locations/LocationsScreen.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/locations/LocationsScreen.kt @@ -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") } diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanScreen.kt b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanScreen.kt index 891aad9..727e416 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanScreen.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/scan/ScanScreen.kt @@ -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, @@ -309,7 +309,7 @@ private fun FoundBookSheet( } @Composable -private fun ManualEntrySheet( +internal fun ManualEntrySheet( isbn13: String, bookcases: List, shelves: List, @@ -362,7 +362,7 @@ private fun ManualEntrySheet( } @Composable -private fun ShelfPicker( +internal fun ShelfPicker( bookcases: List, shelves: List, selectedShelfId: String?, diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsScreen.kt b/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsScreen.kt index 22b2b86..ac068ac 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsScreen.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/settings/SettingsScreen.kt @@ -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 { diff --git a/app/app/src/test/java/org/modg/bookshelf/livesync/LiveSyncTest.kt b/app/app/src/test/java/org/modg/bookshelf/livesync/LiveSyncTest.kt new file mode 100644 index 0000000..31b904b --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/livesync/LiveSyncTest.kt @@ -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() + 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(), + ) +} diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/screens/LibraryScreenPaparazziTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/screens/LibraryScreenPaparazziTest.kt new file mode 100644 index 0000000..0b964e8 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/ui/screens/LibraryScreenPaparazziTest.kt @@ -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, 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() } } + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/screens/ScreenFixtures.kt b/app/app/src/test/java/org/modg/bookshelf/ui/screens/ScreenFixtures.kt new file mode 100644 index 0000000..e646a11 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/ui/screens/ScreenFixtures.kt @@ -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, + 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.", + ) +} diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryEmptyLight_library-empty-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryEmptyLight_library-empty-dark.png new file mode 100644 index 0000000..6fdb470 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryEmptyLight_library-empty-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryEmptyLight_library-empty-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryEmptyLight_library-empty-light.png new file mode 100644 index 0000000..8005998 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryEmptyLight_library-empty-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryPopulatedLight_library-populated-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryPopulatedLight_library-populated-dark.png new file mode 100644 index 0000000..dbecbd1 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryPopulatedLight_library-populated-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryPopulatedLight_library-populated-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryPopulatedLight_library-populated-light.png new file mode 100644 index 0000000..9193e2e Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryPopulatedLight_library-populated-light.png differ diff --git a/logs/F1-livesync.sid b/logs/F1-livesync.sid new file mode 100644 index 0000000..2626408 --- /dev/null +++ b/logs/F1-livesync.sid @@ -0,0 +1 @@ +70375eac-5373-48c5-a3ae-9a90d25aa73d diff --git a/logs/F2-release.sid b/logs/F2-release.sid new file mode 100644 index 0000000..212142f --- /dev/null +++ b/logs/F2-release.sid @@ -0,0 +1 @@ +7bab87f8-8de1-4e18-b039-e5a53c2816b1 diff --git a/logs/WAVE3-DONE b/logs/WAVE3-DONE new file mode 100644 index 0000000..71ce5df --- /dev/null +++ b/logs/WAVE3-DONE @@ -0,0 +1,15 @@ +=== WAVE3-DONE written 2026-09-06T10:52:02+00:00 === +Workers finished. The orchestrator was NOT necessarily alive for this. + +--- E1-shell --- +[2026-09-06T10:46:42+00:00] E1-shell: SUCCESS after 1 attempt(s), 0 quota wait(s) +cost=$2.8037944 turns=6 + +--- E2-books --- +[2026-09-06T10:24:59+00:00] E2-books: QUOTA hit (wait #1). sleeping 600s then probing again. +[2026-09-06T10:35:41+00:00] E2-books: SUCCESS after 2 attempt(s), 1 quota wait(s) +cost=$0.25929520000000006 turns=5 + +NEXT: orchestrator must independently verify before accepting: + cd ~/bookshelf && ./tasks/gw assembleDebug && ./tasks/gw testDebugUnitTest + git status --porcelain # boundary check: who touched what diff --git a/server/live-sync-test.sh b/server/live-sync-test.sh new file mode 100755 index 0000000..c4f8380 --- /dev/null +++ b/server/live-sync-test.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# +# live-sync-test.sh — runs LiveSyncTest against a REAL PocketBase. +# +# This is NOT part of `tasks/gw testDebugUnitTest` (that must stay green on +# machines with no PocketBase running). LiveSyncTest gates itself behind the +# LIVE_SYNC env var via org.junit.Assume, so invoking the normal test task +# without this script just reports it SKIPPED. +# +# Usage: +# server/live-sync-test.sh +# PB_URL=http://127.0.0.1:8090 server/live-sync-test.sh +# +# Creates a throwaway app user (idempotent -- ignores "already exists") via +# create-user.sh, then runs only LiveSyncTest with LIVE_SYNC=1 and the +# matching creds exported as environment variables. The forked Gradle test +# JVM inherits the environment of the process that launches it, which is how +# these reach the test without touching app/app/build.gradle.kts (owned by +# another worker this wave). + +set -u -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +PB_URL="${PB_URL:-http://127.0.0.1:8090}" +PB_URL="${PB_URL%/}" +LIVE_EMAIL="${LIVE_SYNC_EMAIL:-livetest@example.com}" +LIVE_PASSWORD="${LIVE_SYNC_PASSWORD:-livetest-passw0rd-1}" + +log() { printf '%s\n' "$*" >&2; } +ok() { printf '\033[32m✓\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[31m✗ ERROR:\033[0m %s\n' "$*" >&2; exit 1; } + +command -v curl >/dev/null 2>&1 || fail "curl is required but not installed." + +if ! curl -sf -o /dev/null --connect-timeout 5 "$PB_URL/api/health"; then + fail "Cannot reach $PB_URL/api/health -- is PocketBase running? (sprite-env services restart pocketbase)" +fi + +log "Ensuring throwaway test user exists: $LIVE_EMAIL" +CREATE_OUTPUT="$(PB_URL="$PB_URL" "$SCRIPT_DIR/create-user.sh" "$LIVE_EMAIL" "$LIVE_PASSWORD" "Live Sync Test" 2>&1)" +if echo "$CREATE_OUTPUT" | grep -qi "not_unique\|already in use\|Created user"; then + ok "Test user ready" +else + log "$CREATE_OUTPUT" + fail "Could not create or confirm the throwaway test user." +fi + +export LIVE_SYNC=1 +export LIVE_SYNC_BASE_URL="$PB_URL" +export LIVE_SYNC_EMAIL="$LIVE_EMAIL" +export LIVE_SYNC_PASSWORD="$LIVE_PASSWORD" + +log "Running LiveSyncTest against $PB_URL as $LIVE_EMAIL" +"$REPO_ROOT/tasks/gw" testDebugUnitTest --tests "org.modg.bookshelf.livesync.LiveSyncTest" --rerun