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:
@@ -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.",
|
||||
)
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Reference in New Issue
Block a user