diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/add/AddBookModels.kt b/app/app/src/main/java/org/modg/bookshelf/ui/add/AddBookModels.kt new file mode 100644 index 0000000..c0080cb --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/add/AddBookModels.kt @@ -0,0 +1,65 @@ +package org.modg.bookshelf.ui.add + +import org.modg.bookshelf.data.metadata.IsbnUtils + +/** + * The add-by-hand form's fields, independent of any particular entry point. + * [org.modg.bookshelf.ui.nav.Routes.addBook] pre-fills [title]/[isbn] today — + * from the scan screen's manual-ISBN dialog, or the library FAB with neither. + * A follow-up wave (online search) is expected to pre-fill more fields from a + * search hit; [fromRouteArgs] is the one place the route-args-to-draft mapping + * lives, so extending it later is a small, localized change. + */ +data class BookDraft( + val title: String = "", + val subtitle: String = "", + val authors: String = "", + val publisher: String = "", + val publishedDate: String = "", + val pages: String = "", + val isbn: String = "", + val description: String = "", +) { + companion object { + fun fromRouteArgs(title: String?, isbn: String?): BookDraft = + BookDraft(title = title.orEmpty(), isbn = isbn.orEmpty()) + } +} + +/** + * [BookDraft.isbn] parsed per SPEC's "ISBN-10 or ISBN-13, with or without + * hyphens" (same rule [IsbnUtils.toIsbn13] already enforces for scanning). + * Blank is valid — no ISBN is a legal book. A non-blank value that fails to + * parse is [Invalid], never silently dropped: wave 5 fixed exactly that + * silent-discard bug in the scan screen's manual-ISBN dialog, and this form + * must not reintroduce it. + */ +sealed interface IsbnFieldState { + data object Blank : IsbnFieldState + data class Valid(val isbn13: String, val isbn10: String?) : IsbnFieldState + data object Invalid : IsbnFieldState +} + +fun validateIsbn(raw: String): IsbnFieldState { + if (raw.isBlank()) return IsbnFieldState.Blank + val isbn13 = IsbnUtils.toIsbn13(raw) ?: return IsbnFieldState.Invalid + val normalized = IsbnUtils.normalize(raw) + // Keep the ISBN-10 alongside the normalized ISBN-13 only when the user actually + // typed a valid ISBN-10 — a 13-digit entry has no ISBN-10 form to record. + val isbn10 = normalized.takeIf { it.length == 10 && IsbnUtils.isValidIsbn10(it) } + return IsbnFieldState.Valid(isbn13, isbn10) +} + +/** [BookDraft.pages] — SPEC "digits only". Blank means unknown, not zero. */ +sealed interface PagesFieldState { + data object Blank : PagesFieldState + data class Valid(val pages: Int) : PagesFieldState + data object Invalid : PagesFieldState +} + +fun validatePages(raw: String): PagesFieldState { + if (raw.isBlank()) return PagesFieldState.Blank + if (!raw.all(Char::isDigit)) return PagesFieldState.Invalid + val pages = raw.toIntOrNull() ?: return PagesFieldState.Invalid + return PagesFieldState.Valid(pages) +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/add/AddBookScreen.kt b/app/app/src/main/java/org/modg/bookshelf/ui/add/AddBookScreen.kt new file mode 100644 index 0000000..3a95d79 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/add/AddBookScreen.kt @@ -0,0 +1,319 @@ +package org.modg.bookshelf.ui.add + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import org.modg.bookshelf.AppContainer +import org.modg.bookshelf.ui.components.BookshelfScaffold +import org.modg.bookshelf.ui.components.PaperSurface +import org.modg.bookshelf.ui.components.PrimaryButton +import org.modg.bookshelf.ui.components.SecondaryButton +import org.modg.bookshelf.ui.scan.DuplicateStatus +import org.modg.bookshelf.ui.scan.ShelfPicker + +/** + * SPEC gap: there was no way to add a book with no barcode — every save path + * started from a scanned/typed ISBN. A full screen, not a sheet: the library + * has no camera context to anchor a bottom sheet to (unlike [org.modg.bookshelf.ui.scan.ManualEntrySheet]), + * and this form is longer than that sheet's two fields. + */ +@Composable +fun AddBookScreen( + initialTitle: String?, + initialIsbn: String?, + onBack: () -> Unit, + onSaved: (bookId: String) -> Unit, + container: AppContainer, +) { + val viewModel: AddBookViewModel = viewModel( + factory = viewModelFactory { + initializer { + AddBookViewModel( + bookRepository = container.bookRepository, + locationRepository = container.locationRepository, + settingsStore = container.settingsStore, + initialDraft = BookDraft.fromRouteArgs(initialTitle, initialIsbn), + ) + } + }, + ) + val state by viewModel.uiState.collectAsState() + + AddBookContent( + state = state, + callbacks = AddBookCallbacks( + onTitleChanged = viewModel::onTitleChanged, + onSubtitleChanged = viewModel::onSubtitleChanged, + onAuthorsChanged = viewModel::onAuthorsChanged, + onPublisherChanged = viewModel::onPublisherChanged, + onPublishedDateChanged = viewModel::onPublishedDateChanged, + onPagesChanged = viewModel::onPagesChanged, + onIsbnChanged = viewModel::onIsbnChanged, + onDescriptionChanged = viewModel::onDescriptionChanged, + onShelfSelected = viewModel::onShelfSelected, + onSave = { viewModel.save(onSaved) }, + onSaveAndAddAnother = viewModel::saveAndAddAnother, + onBack = onBack, + ), + ) +} + +/** + * Every callback [AddBookContent] needs, grouped into one object per the task's + * `AddBookContent(state, callbacks)` shape — so the Paparazzi test can render + * this REAL composable fed with static data, instead of a hand-rolled lookalike + * (wave 6 shipped exactly that for Locations; HANDOFF.md records it as a known + * soft spot this wave was told not to repeat). + */ +data class AddBookCallbacks( + val onTitleChanged: (String) -> Unit, + val onSubtitleChanged: (String) -> Unit, + val onAuthorsChanged: (String) -> Unit, + val onPublisherChanged: (String) -> Unit, + val onPublishedDateChanged: (String) -> Unit, + val onPagesChanged: (String) -> Unit, + val onIsbnChanged: (String) -> Unit, + val onDescriptionChanged: (String) -> Unit, + val onShelfSelected: (String?) -> Unit, + val onSave: () -> Unit, + val onSaveAndAddAnother: () -> Unit, + val onBack: () -> Unit, +) + +@Composable +fun AddBookContent(state: AddBookUiState, callbacks: AddBookCallbacks, modifier: Modifier = Modifier) { + val focusManager = LocalFocusManager.current + val titleFocusRequester = remember { FocusRequester() } + + // "Save & add another" clears the form and SPEC wants focus back in Title. + // savedCount only increments on a successful save (not on every keystroke), and + // the empty-title check keeps this from re-stealing focus if the user has + // already started typing the next book by the time recomposition catches up. + LaunchedEffect(state.savedCount) { + if (state.savedCount > 0 && state.draft.title.isEmpty()) { + runCatching { titleFocusRequester.requestFocus() } + } + } + + BookshelfScaffold( + title = "Add a book", + navigationIcon = { + IconButton(onClick = callbacks.onBack) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") + } + }, + modifier = modifier, + ) { innerPadding -> + PaperSurface(modifier = Modifier.fillMaxSize()) { + // Same fix as the setup screen (see its KDoc): imePadding OUTSIDE + // verticalScroll shrinks the scrollable viewport instead of letting the + // keyboard cover it, so a field low in this (longer) form isn't + // stranded behind the IME. BookshelfScaffold's own innerPadding only + // accounts for the top/bottom bars, not the IME, so this screen still + // needs to handle it itself. + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + if (state.lastSavedTitle != null) { + Text( + text = "Saved “${state.lastSavedTitle}”. ${state.savedCount} added this session.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 12.dp), + ) + } + + OutlinedTextField( + value = state.draft.title, + onValueChange = callbacks.onTitleChanged, + label = { Text("Title") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }), + modifier = Modifier.fillMaxWidth().focusRequester(titleFocusRequester), + ) + OutlinedTextField( + value = state.draft.subtitle, + onValueChange = callbacks.onSubtitleChanged, + label = { Text("Subtitle") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }), + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + OutlinedTextField( + value = state.draft.authors, + onValueChange = callbacks.onAuthorsChanged, + label = { Text("Authors (comma-separated)") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }), + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + OutlinedTextField( + value = state.draft.publisher, + onValueChange = callbacks.onPublisherChanged, + label = { Text("Publisher") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }), + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + OutlinedTextField( + value = state.draft.publishedDate, + onValueChange = callbacks.onPublishedDateChanged, + label = { Text("Published") }, + placeholder = { Text("1960, or 1999-04") }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }), + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + OutlinedTextField( + value = state.draft.pages, + onValueChange = callbacks.onPagesChanged, + label = { Text("Pages") }, + singleLine = true, + isError = state.pagesError != null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }), + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + if (state.pagesError != null) { + Text( + text = state.pagesError!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 4.dp), + ) + } + OutlinedTextField( + value = state.draft.isbn, + onValueChange = callbacks.onIsbnChanged, + label = { Text("ISBN") }, + placeholder = { Text("Optional — ISBN-10 or ISBN-13") }, + singleLine = true, + isError = state.isbnError != null, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }), + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + if (state.isbnError != null) { + Text( + text = state.isbnError!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 4.dp), + ) + } + val duplicate = state.duplicate + if (duplicate is DuplicateStatus.AlreadyOwned) { + // A warning, not a block — SPEC: two copies of a book is legal. Same + // copy the scan sheet uses, for consistency (see FoundBookSheet). + Text( + text = "You already own this book (\"${duplicate.title}\").", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 4.dp), + ) + } + OutlinedTextField( + value = state.draft.description, + onValueChange = callbacks.onDescriptionChanged, + label = { Text("Description") }, + minLines = 3, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + + ShelfPicker( + bookcases = state.bookcases, + shelves = state.shelves, + selectedShelfId = state.selectedShelfId, + recentShelfId = state.recentShelfId, + onShelfSelected = callbacks.onShelfSelected, + ) + + if (state.saveError != null) { + Text( + text = "Couldn't save: ${state.saveError}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 12.dp), + ) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SecondaryButton( + text = "Save & add another", + enabled = state.canSave, + onClick = callbacks.onSaveAndAddAnother, + modifier = Modifier.weight(1f), + ) + PrimaryButton( + text = if (state.isSaving) "Saving…" else "Save", + enabled = state.canSave, + onClick = callbacks.onSave, + modifier = Modifier.weight(1f), + ) + } + } + } + } +} diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/add/AddBookViewModel.kt b/app/app/src/main/java/org/modg/bookshelf/ui/add/AddBookViewModel.kt new file mode 100644 index 0000000..cf39f57 --- /dev/null +++ b/app/app/src/main/java/org/modg/bookshelf/ui/add/AddBookViewModel.kt @@ -0,0 +1,226 @@ +package org.modg.bookshelf.ui.add + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.modg.bookshelf.data.local.BookcaseEntity +import org.modg.bookshelf.data.local.ShelfEntity +import org.modg.bookshelf.data.prefs.SettingsStore +import org.modg.bookshelf.data.repo.BookRepository +import org.modg.bookshelf.data.repo.LocationRepository +import org.modg.bookshelf.ui.scan.DuplicateCheck +import org.modg.bookshelf.ui.scan.DuplicateStatus + +/** Everything [AddBookContent] needs to render the form. */ +data class AddBookUiState( + val draft: BookDraft = BookDraft(), + val bookcases: List = emptyList(), + val shelves: List = emptyList(), + val selectedShelfId: String? = null, + val recentShelfId: String? = null, + val duplicate: DuplicateStatus = DuplicateStatus.New, + val isSaving: Boolean = false, + val saveError: String? = null, + val savedCount: Int = 0, + val lastSavedTitle: String? = null, +) { + val isbnError: String? + get() = if (validateIsbn(draft.isbn) is IsbnFieldState.Invalid) { + "That isn't a valid ISBN — check the digits." + } else { + null + } + + val pagesError: String? + get() = if (validatePages(draft.pages) is PagesFieldState.Invalid) "Pages must be a number." else null + + /** Title is the only required field; a field in error or a save already in flight blocks both Save actions. */ + val canSave: Boolean + get() = draft.title.isNotBlank() && isbnError == null && pagesError == null && !isSaving +} + +/** + * Owns the add-by-hand form (SPEC gap: no way to add a book with no barcode). + * Offline-first like every other screen: [bookRepository]/[locationRepository] + * are Room only, nothing here touches the network. + */ +class AddBookViewModel( + private val bookRepository: BookRepository, + locationRepository: LocationRepository, + private val settingsStore: SettingsStore, + initialDraft: BookDraft = BookDraft(), +) : ViewModel() { + + private data class FormState( + val draft: BookDraft, + val selectedShelfId: String? = null, + val duplicate: DuplicateStatus = DuplicateStatus.New, + val isSaving: Boolean = false, + val saveError: String? = null, + val savedCount: Int = 0, + val lastSavedTitle: String? = null, + ) + + private val _formState = MutableStateFlow(FormState(draft = initialDraft)) + private var duplicateCheckJob: Job? = null + + val bookcases: StateFlow> = locationRepository.observeBookcases() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + val shelves: StateFlow> = locationRepository.observeShelves() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** Surfaced as the shelf picker's "Recent" shortcut, same as the scan screen's. */ + val recentShelfId: StateFlow = settingsStore.lastShelfId + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + // WhileSubscribed, matching Library/LocationsViewModel's uiState — but note this + // means .value only reflects post-action state to a caller that is actively + // collecting (e.g. Compose's collectAsState, or a test using .first()); a bare + // .value read with no live collector sees only the seed passed to stateIn. + val uiState: StateFlow = combine( + _formState, bookcases, shelves, recentShelfId, + ) { form, cases, shelfList, recent -> + AddBookUiState( + draft = form.draft, + bookcases = cases, + shelves = shelfList, + selectedShelfId = form.selectedShelfId, + recentShelfId = recent, + duplicate = form.duplicate, + isSaving = form.isSaving, + saveError = form.saveError, + savedCount = form.savedCount, + lastSavedTitle = form.lastSavedTitle, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AddBookUiState(draft = initialDraft)) + + init { + // SPEC: pre-select the remembered last shelf. The scan sheet only offers this + // as a "Recent" shortcut because it also has a per-scan shelf that sticks + // across a continuous session; a hand-entry form has no such session anchor, + // so here the remembered shelf IS the default. + viewModelScope.launch { + val remembered = settingsStore.lastShelfId.first() + if (remembered != null) _formState.update { it.copy(selectedShelfId = remembered) } + } + if (initialDraft.isbn.isNotBlank()) scheduleDuplicateCheck(initialDraft.isbn) + } + + fun onTitleChanged(value: String) = updateDraft { it.copy(title = value) } + fun onSubtitleChanged(value: String) = updateDraft { it.copy(subtitle = value) } + fun onAuthorsChanged(value: String) = updateDraft { it.copy(authors = value) } + fun onPublisherChanged(value: String) = updateDraft { it.copy(publisher = value) } + fun onPublishedDateChanged(value: String) = updateDraft { it.copy(publishedDate = value) } + fun onPagesChanged(value: String) = updateDraft { it.copy(pages = value) } + fun onDescriptionChanged(value: String) = updateDraft { it.copy(description = value) } + + fun onIsbnChanged(value: String) { + updateDraft { it.copy(isbn = value) } + scheduleDuplicateCheck(value) + } + + fun onShelfSelected(shelfId: String?) { + _formState.update { it.copy(selectedShelfId = shelfId) } + } + + private inline fun updateDraft(transform: (BookDraft) -> BookDraft) { + _formState.update { it.copy(draft = transform(it.draft)) } + } + + /** SPEC scan-sheet parity: a warning, not a block — two copies of a book is legal. */ + private fun scheduleDuplicateCheck(rawIsbn: String) { + duplicateCheckJob?.cancel() + val isbn13 = (validateIsbn(rawIsbn) as? IsbnFieldState.Valid)?.isbn13 + if (isbn13 == null) { + _formState.update { it.copy(duplicate = DuplicateStatus.New) } + return + } + duplicateCheckJob = viewModelScope.launch { + val existing = bookRepository.findByIsbn13(isbn13) + _formState.update { it.copy(duplicate = DuplicateCheck.check(existing)) } + } + } + + /** "Save" — navigates to the new book's detail screen on success (see [AddBookScreen]). */ + fun save(onSaved: (bookId: String) -> Unit) { + viewModelScope.launch { + val id = performSave(clearAfterSave = false) + if (id != null) onSaved(id) + } + } + + /** "Save & add another" — SPEC's real use case, shelving a box of books at once. */ + fun saveAndAddAnother() { + viewModelScope.launch { performSave(clearAfterSave = true) } + } + + /** + * The actual save, split out so tests can await it directly — same reasoning + * as [org.modg.bookshelf.ui.scan.ScanViewModel.performSave]. HANDOFF.md flagged + * that ViewModel's save paths as still making unguarded Room calls after wave 8 + * guarded only the lookup path; this one is guarded from the start. Catches + * [Throwable] — [CancellationException] is rethrown FIRST, ahead of that catch, + * because a cancelled save (screen left mid-save) is normal control flow, not a + * failure, and must propagate rather than being reported as one. Re-entrancy is + * guarded here rather than trusted to the caller's disabled-button state, so a + * genuine double-tap (two taps landing before recomposition disables the + * button) still can't create two books. + */ + internal suspend fun performSave(clearAfterSave: Boolean): String? { + if (_formState.value.isSaving) return null + _formState.update { it.copy(isSaving = true, saveError = null) } + val form = _formState.value + val draft = form.draft + return try { + val isbnValidation = validateIsbn(draft.isbn) + val isbn13 = (isbnValidation as? IsbnFieldState.Valid)?.isbn13 + val isbn10 = (isbnValidation as? IsbnFieldState.Valid)?.isbn10 + val pageCount = (validatePages(draft.pages) as? PagesFieldState.Valid)?.pages + val authors = draft.authors.split(",").map(String::trim).filter(String::isNotEmpty) + val savedTitle = draft.title.trim() + val bookId = bookRepository.createBook( + title = savedTitle, + subtitle = draft.subtitle.trim().ifEmpty { null }, + authors = authors, + isbn13 = isbn13, + isbn10 = isbn10, + publisher = draft.publisher.trim().ifEmpty { null }, + publishedDate = draft.publishedDate.trim().ifEmpty { null }, + pageCount = pageCount, + description = draft.description.trim().ifEmpty { null }, + shelfId = form.selectedShelfId, + ) + rememberShelf(form.selectedShelfId) + _formState.update { + it.copy( + draft = if (clearAfterSave) BookDraft() else it.draft, + isSaving = false, + saveError = null, + savedCount = it.savedCount + 1, + lastSavedTitle = savedTitle, + ) + } + if (clearAfterSave) scheduleDuplicateCheck("") // the ISBN field just cleared; the warning must too + bookId + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + _formState.update { it.copy(isSaving = false, saveError = e.javaClass.simpleName) } + null + } + } + + /** "Not shelved" (null) must never overwrite the memory — it isn't a shelf. */ + private suspend fun rememberShelf(shelfId: String?) { + if (shelfId != null) settingsStore.setLastShelfId(shelfId) + } +} 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 cb6c2b9..cf1d5b5 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 @@ -11,8 +11,11 @@ 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.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material.icons.outlined.EditNote import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.QrCodeScanner import androidx.compose.material.icons.outlined.Search @@ -26,6 +29,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable @@ -54,6 +58,7 @@ import org.modg.bookshelf.ui.components.EmptyState import org.modg.bookshelf.ui.components.BookshelfScaffold import org.modg.bookshelf.ui.components.PaperSurface import org.modg.bookshelf.ui.components.PrimaryButton +import org.modg.bookshelf.ui.components.SecondaryButton import org.modg.bookshelf.ui.components.SyncStatus import org.modg.bookshelf.ui.components.SyncStatusBar @@ -68,6 +73,7 @@ fun LibraryScreen( shelfIdFilter: String?, onBookClick: (String) -> Unit, onScanClick: () -> Unit, + onAddByHandClick: () -> Unit, onLocationsClick: () -> Unit, onSettingsClick: () -> Unit, container: AppContainer, @@ -105,8 +111,18 @@ fun LibraryScreen( } }, floatingActionButton = { - FloatingActionButton(onClick = onScanClick) { - Icon(Icons.Outlined.QrCodeScanner, contentDescription = "Scan a book") + // Scan stays the one primary action (it's the SPEC use case — a box of + // books, one after another); "Add by hand" is a small satellite FAB + // stacked above it rather than a second full-size FAB competing for + // attention, or a toolbar icon that would bury it below Locations/Settings. + Column(horizontalAlignment = Alignment.End) { + SmallFloatingActionButton(onClick = onAddByHandClick) { + Icon(Icons.Outlined.EditNote, contentDescription = "Add a book by hand") + } + Spacer(modifier = Modifier.height(12.dp)) + FloatingActionButton(onClick = onScanClick) { + Icon(Icons.Outlined.QrCodeScanner, contentDescription = "Scan a book") + } } }, syncStatusBar = { @@ -137,7 +153,12 @@ fun LibraryScreen( !uiState.hasAnyBooks -> EmptyState( title = "Your shelves are empty", message = "Scan a barcode to add your first book.", - action = { PrimaryButton(text = "Scan a book", onClick = onScanClick) }, + action = { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PrimaryButton(text = "Scan a book", onClick = onScanClick) + SecondaryButton(text = "Add by hand", onClick = onAddByHandClick) + } + }, ) uiState.books.isEmpty() -> EmptyState( title = "No books match", diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/nav/BookshelfNavHost.kt b/app/app/src/main/java/org/modg/bookshelf/ui/nav/BookshelfNavHost.kt index 4bd5985..fd7f6a5 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/nav/BookshelfNavHost.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/nav/BookshelfNavHost.kt @@ -8,6 +8,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import org.modg.bookshelf.AppContainer +import org.modg.bookshelf.ui.add.AddBookScreen import org.modg.bookshelf.ui.detail.DetailScreen import org.modg.bookshelf.ui.library.LibraryScreen import org.modg.bookshelf.ui.locations.LocationsScreen @@ -52,6 +53,7 @@ fun BookshelfNavHost( shelfIdFilter = backStackEntry.arguments?.getString(Routes.LIBRARY_SHELF_ARG), onBookClick = { bookId -> navController.navigate(Routes.detail(bookId)) }, onScanClick = { navController.navigate(Routes.SCAN) }, + onAddByHandClick = { navController.navigate(Routes.addBook()) }, onLocationsClick = { navController.navigate(Routes.LOCATIONS) }, onSettingsClick = { navController.navigate(Routes.SETTINGS) }, container = container, @@ -70,6 +72,37 @@ fun BookshelfNavHost( composable(Routes.SCAN) { ScanScreen( onBack = { navController.popBackStack() }, + onEnterByHand = { navController.navigate(Routes.addBook()) }, + container = container, + ) + } + composable( + route = Routes.ADD_BOOK, + arguments = listOf( + navArgument(Routes.ADD_BOOK_TITLE_ARG) { + type = NavType.StringType + nullable = true + defaultValue = null + }, + navArgument(Routes.ADD_BOOK_ISBN_ARG) { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), + ) { backStackEntry -> + AddBookScreen( + initialTitle = backStackEntry.arguments?.getString(Routes.ADD_BOOK_TITLE_ARG), + initialIsbn = backStackEntry.arguments?.getString(Routes.ADD_BOOK_ISBN_ARG), + onBack = { navController.popBackStack() }, + onSaved = { bookId -> + // Removes the add screen from the back stack, per the task's "Save" + // contract, so Back from detail returns to the library rather than + // to a form that was just successfully submitted. + navController.navigate(Routes.detail(bookId)) { + popUpTo(Routes.ADD_BOOK) { inclusive = true } + } + }, container = container, ) } diff --git a/app/app/src/main/java/org/modg/bookshelf/ui/nav/Routes.kt b/app/app/src/main/java/org/modg/bookshelf/ui/nav/Routes.kt index 5bb9f8f..12b6f41 100644 --- a/app/app/src/main/java/org/modg/bookshelf/ui/nav/Routes.kt +++ b/app/app/src/main/java/org/modg/bookshelf/ui/nav/Routes.kt @@ -1,5 +1,7 @@ package org.modg.bookshelf.ui.nav +import android.net.Uri + /** * Route string constants for [BookshelfNavHost]. Fixed by the wave-3 screen * contract shared with Worker E2 — do not rename or restructure these; E2's @@ -16,6 +18,37 @@ object Routes { const val LOCATIONS = "locations" const val SETTINGS = "settings" + const val ADD_BOOK_TITLE_ARG = "title" + const val ADD_BOOK_ISBN_ARG = "isbn" + private const val ADD_BOOK_BASE = "add" + + /** + * Both args are optional (wave 9: "add a book by hand" with nothing pre-filled) + * — nullable/defaultValue-null args, same pattern [LIBRARY_WITH_SHELF] uses so + * this one route also matches the bare "add" entry with neither supplied. + */ + const val ADD_BOOK = "$ADD_BOOK_BASE?$ADD_BOOK_TITLE_ARG={$ADD_BOOK_TITLE_ARG}&$ADD_BOOK_ISBN_ARG={$ADD_BOOK_ISBN_ARG}" + fun libraryFilteredByShelf(shelfId: String) = "library?shelfId=$shelfId" fun detail(bookId: String) = "detail/$bookId" + + /** + * Builds the add-by-hand route, percent-encoding [title]/[isbn] so values + * containing `&`, `?`, `/`, `#` etc. (titles routinely do) survive as a single + * query value instead of corrupting the route string. [Uri.encode] rather than + * [java.net.URLEncoder] because the latter encodes space as `+`, which + * Navigation Compose's Uri-pattern argument matching does NOT decode back to + * space (it does standard percent-decoding, not form-decoding) — see + * RoutesTest for the round-trip this must survive. A null arg is omitted + * entirely rather than encoded as the literal string "null", so a caller with + * nothing to pre-fill (e.g. the plain library FAB) gets exactly the bare + * "add" route wave 9's [org.modg.bookshelf.ui.add.AddBookScreen] expects. + */ + fun addBook(title: String? = null, isbn: String? = null): String { + val query = buildList { + title?.let { add("$ADD_BOOK_TITLE_ARG=${Uri.encode(it)}") } + isbn?.let { add("$ADD_BOOK_ISBN_ARG=${Uri.encode(it)}") } + } + return if (query.isEmpty()) ADD_BOOK_BASE else "$ADD_BOOK_BASE?" + query.joinToString("&") + } } 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 bd299f0..10cf6da 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 @@ -33,6 +33,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -80,6 +81,7 @@ import org.modg.bookshelf.ui.components.ShelfPickerSheet @Composable fun ScanScreen( onBack: () -> Unit, + onEnterByHand: () -> Unit, container: AppContainer, ) { val viewModel: ScanViewModel = viewModel( @@ -205,6 +207,7 @@ fun ScanScreen( ManualIsbnDialog( onDismiss = { showManualEntry = false }, onSubmit = { isbn -> showManualEntry = false; viewModel.manualIsbnEntered(isbn) }, + onEnterByHand = { showManualEntry = false; onEnterByHand() }, ) } } @@ -523,7 +526,7 @@ internal fun ShelfPicker( } @Composable -private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit) { +private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit, onEnterByHand: () -> Unit) { var text by remember { mutableStateOf("") } // Validate here rather than letting ScanViewModel.manualIsbnEntered drop an // unparseable ISBN on the floor. A typed-in check digit is easy to get wrong, @@ -561,6 +564,12 @@ private fun ManualIsbnDialog(onDismiss: () -> Unit, onSubmit: (String) -> Unit) modifier = Modifier.padding(top = 4.dp), ) } + // Escape hatch for the books this dialog can't help with at all — SPEC + // gap: older/self-published/foreign books often have no ISBN, and this + // dialog only ever accepts one. + TextButton(onClick = onEnterByHand, modifier = Modifier.padding(top = 8.dp)) { + Text("No ISBN? Enter the details by hand") + } } }, confirmButton = { diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/add/AddBookModelsTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/add/AddBookModelsTest.kt new file mode 100644 index 0000000..ef5dbca --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/ui/add/AddBookModelsTest.kt @@ -0,0 +1,126 @@ +package org.modg.bookshelf.ui.add + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pure validation logic for the add-by-hand form — no ViewModel/Room needed. + * See [AddBookViewModelTest] for how [AddBookUiState.canSave] and the save + * paths build on these. + */ +class AddBookModelsTest { + + // --- BookDraft.fromRouteArgs --- + + @Test + fun `fromRouteArgs fills title and isbn when both present`() { + val draft = BookDraft.fromRouteArgs(title = "Dune", isbn = "9780441013593") + + assertEquals("Dune", draft.title) + assertEquals("9780441013593", draft.isbn) + } + + @Test + fun `fromRouteArgs defaults absent args to blank, not the literal string null`() { + val draft = BookDraft.fromRouteArgs(title = null, isbn = null) + + assertEquals("", draft.title) + assertEquals("", draft.isbn) + } + + // --- validateIsbn --- + + @Test + fun `blank isbn is valid — no ISBN is a legal book`() { + assertEquals(IsbnFieldState.Blank, validateIsbn("")) + assertEquals(IsbnFieldState.Blank, validateIsbn(" ")) + } + + @Test + fun `valid isbn13 is accepted and carries no isbn10`() { + val result = validateIsbn("9780441013593") + + val valid = result as? IsbnFieldState.Valid + checkNotNull(valid) { "expected Valid, got $result" } + assertEquals("9780441013593", valid.isbn13) + assertNull(valid.isbn10) + } + + @Test + fun `valid isbn10 is normalized to isbn13 and the isbn10 is kept`() { + val result = validateIsbn("0-441-01359-7") // Dune's ISBN-10, hyphenated + + val valid = result as? IsbnFieldState.Valid + checkNotNull(valid) { "expected Valid, got $result" } + assertEquals("9780441013593", valid.isbn13) + assertEquals("0441013597", valid.isbn10) + } + + @Test + fun `a bad checksum is Invalid, not silently dropped`() { + // wave 5 fixed exactly this silent-discard bug in the scan screen's + // manual-ISBN dialog; this form must not reintroduce it. + assertEquals(IsbnFieldState.Invalid, validateIsbn("9780441013594")) // last digit wrong + assertEquals(IsbnFieldState.Invalid, validateIsbn("not-an-isbn")) + } + + // --- validatePages --- + + @Test + fun `blank pages is valid and means unknown, not zero`() { + assertEquals(PagesFieldState.Blank, validatePages("")) + } + + @Test + fun `digit-only pages is valid`() { + assertEquals(PagesFieldState.Valid(412), validatePages("412")) + } + + @Test + fun `non-digit pages is rejected`() { + assertEquals(PagesFieldState.Invalid, validatePages("412pp")) + assertEquals(PagesFieldState.Invalid, validatePages("-5")) + assertEquals(PagesFieldState.Invalid, validatePages("4.5")) + } + + // --- AddBookUiState.canSave --- + + @Test + fun `canSave is false with a blank title`() { + val state = AddBookUiState(draft = BookDraft(title = "")) + + assertTrue(!state.canSave) + } + + @Test + fun `canSave is true with only a title — every other field is optional`() { + val state = AddBookUiState(draft = BookDraft(title = "Dune")) + + assertTrue(state.canSave) + } + + @Test + fun `canSave is false while an isbn error is showing`() { + val state = AddBookUiState(draft = BookDraft(title = "Dune", isbn = "not-an-isbn")) + + assertTrue(!state.canSave) + assertEquals("That isn't a valid ISBN — check the digits.", state.isbnError) + } + + @Test + fun `canSave is false while a pages error is showing`() { + val state = AddBookUiState(draft = BookDraft(title = "Dune", pages = "412pp")) + + assertTrue(!state.canSave) + assertEquals("Pages must be a number.", state.pagesError) + } + + @Test + fun `canSave is false while a save is already in flight`() { + val state = AddBookUiState(draft = BookDraft(title = "Dune"), isSaving = true) + + assertTrue(!state.canSave) + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/add/AddBookViewModelTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/add/AddBookViewModelTest.kt new file mode 100644 index 0000000..32a5a4f --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/ui/add/AddBookViewModelTest.kt @@ -0,0 +1,292 @@ +package org.modg.bookshelf.ui.add + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.modg.bookshelf.data.local.BookDao +import org.modg.bookshelf.data.local.BookEntity +import org.modg.bookshelf.data.local.BookshelfDatabase +import org.modg.bookshelf.data.prefs.SettingsStore +import org.modg.bookshelf.data.repo.BookRepository +import org.modg.bookshelf.data.repo.LocationRepository +import org.modg.bookshelf.data.repo.decodeAuthors +import org.modg.bookshelf.ui.scan.DuplicateStatus +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class AddBookViewModelTest { + + private lateinit var db: BookshelfDatabase + private lateinit var settingsStore: SettingsStore + private lateinit var locationRepository: LocationRepository + private lateinit var bookRepository: BookRepository + private lateinit var viewModel: AddBookViewModel + private lateinit var shelfId: String + private lateinit var context: android.content.Context + + @Before + fun setUp() = runTest { + // AddBookViewModel.uiState is a combine(...).stateIn(viewModelScope, ...) — + // viewModelScope runs on Dispatchers.Main, which nothing else in this test + // class supplies. Without this, the combine's collector coroutine either + // never runs or runs on Robolectric's paused main looper, so uiState.value + // stays stuck at its initial seed forever (and any `.first { predicate }` + // await hangs). Unconfined so it drains synchronously with no explicit + // advanceUntilIdle() needed at each assertion site. + Dispatchers.setMain(UnconfinedTestDispatcher()) + context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, BookshelfDatabase::class.java) + .allowMainThreadQueries() + .build() + settingsStore = SettingsStore(context) + locationRepository = LocationRepository(db.bookcaseDao(), db.shelfDao(), db.bookDao()) + bookRepository = BookRepository(db.bookDao(), context) + val bookcaseId = locationRepository.createBookcase(name = "Living Room") + shelfId = locationRepository.createShelf(bookcaseId, label = "Top shelf") + + viewModel = AddBookViewModel( + bookRepository = bookRepository, + locationRepository = locationRepository, + settingsStore = settingsStore, + ) + } + + @After + fun tearDown() { + db.close() + Dispatchers.resetMain() + } + + // --- field mapping --- + + @Test + fun `save maps every field, splitting and trimming authors and dropping empty entries`() = runTest { + viewModel.onTitleChanged(" Dune ") + viewModel.onSubtitleChanged("A novel") + viewModel.onAuthorsChanged(" Frank Herbert ,, Brian Herbert , ") + viewModel.onPublisherChanged("Ace") + viewModel.onPublishedDateChanged("1965") + viewModel.onPagesChanged("412") + viewModel.onIsbnChanged("9780441013593") + viewModel.onDescriptionChanged("A desert planet.") + viewModel.onShelfSelected(shelfId) + + val bookId = viewModel.performSave(clearAfterSave = false) + checkNotNull(bookId) + + val saved = bookRepository.getById(bookId) + checkNotNull(saved) + assertEquals("Dune", saved.title) + assertEquals("A novel", saved.subtitle) + assertEquals(listOf("Frank Herbert", "Brian Herbert"), decodeAuthors(saved.authorsJson)) + assertEquals("Ace", saved.publisher) + assertEquals("1965", saved.publishedDate) + assertEquals(412, saved.pageCount) + assertEquals("9780441013593", saved.isbn13) + assertEquals("A desert planet.", saved.description) + assertEquals(shelfId, saved.shelfId) + } + + @Test + fun `blank isbn saves as null, not a silent parse failure`() = runTest { + viewModel.onTitleChanged("Untitled Pamphlet") + + val bookId = viewModel.performSave(clearAfterSave = false) + checkNotNull(bookId) + + val saved = bookRepository.getById(bookId) + checkNotNull(saved) + assertNull(saved.isbn13) + assertNull(saved.isbn10) + } + + @Test + fun `a valid isbn10 is saved as both the normalized isbn13 and the original isbn10`() = runTest { + viewModel.onTitleChanged("Dune") + viewModel.onIsbnChanged("0-441-01359-7") + + val bookId = viewModel.performSave(clearAfterSave = false) + checkNotNull(bookId) + + val saved = bookRepository.getById(bookId) + checkNotNull(saved) + assertEquals("9780441013593", saved.isbn13) + assertEquals("0441013597", saved.isbn10) + } + + // --- duplicate warning --- + + @Test + fun `entering the isbn of an already-owned book surfaces the AlreadyOwned warning`() = runTest { + bookRepository.createBook(title = "Dune", isbn13 = "9780441013593") + + viewModel.onIsbnChanged("9780441013593") + + val duplicate = viewModel.uiState.first { it.duplicate !is DuplicateStatus.New }.duplicate + val alreadyOwned = duplicate as? DuplicateStatus.AlreadyOwned + checkNotNull(alreadyOwned) { "expected AlreadyOwned, got $duplicate" } + assertEquals("Dune", alreadyOwned.title) + } + + @Test + fun `an isbn nobody owns yet stays New`() = runTest { + viewModel.onIsbnChanged("9780441013593") + // Let the duplicate check actually run before asserting on it. + bookRepository.findByIsbn13("9780441013593") + + // .first(), not .value — see the KDoc on AddBookViewModel.uiState. + assertEquals(DuplicateStatus.New, viewModel.uiState.first().duplicate) + } + + // --- save & add another --- + + @Test + fun `save and add another clears the form but keeps the shelf and increments the count`() = runTest { + viewModel.onShelfSelected(shelfId) + viewModel.onTitleChanged("Dune") + viewModel.onAuthorsChanged("Frank Herbert") + + viewModel.performSave(clearAfterSave = true) + + // .first(), not .value — see the KDoc on AddBookViewModel.uiState. + val state = viewModel.uiState.first() + assertEquals("", state.draft.title) + assertEquals("", state.draft.authors) + assertEquals(shelfId, state.selectedShelfId) + assertEquals(1, state.savedCount) + assertEquals("Dune", state.lastSavedTitle) + } + + @Test + fun `plain save does not clear the form`() = runTest { + viewModel.onTitleChanged("Dune") + + viewModel.performSave(clearAfterSave = false) + + // .first(), not .value — see the KDoc on AddBookViewModel.uiState. + val state = viewModel.uiState.first() + assertEquals("Dune", state.draft.title) + assertEquals(1, state.savedCount) + } + + // --- failure handling: §4 of the task, mirroring ScanViewModel's runLookup guard --- + + @Test + fun `a repository failure leaves the form intact, reports the exception class, and does not throw`() = runTest { + val vm = viewModelWithThrowingCreateBook(IllegalStateException("disk full")) + vm.onTitleChanged("Dune") + vm.onAuthorsChanged("Frank Herbert") + + val result = vm.performSave(clearAfterSave = false) // must not throw + + assertNull(result) + // .first(), not .value — see the KDoc on AddBookViewModel.uiState. + val state = vm.uiState.first() + assertEquals("Dune", state.draft.title) // form intact + assertEquals("Frank Herbert", state.draft.authors) + assertEquals("IllegalStateException", state.saveError) + assertTrue(!state.isSaving) + assertEquals(0, state.savedCount) + } + + @Test + fun `a CancellationException during save is rethrown, not swallowed as a failure`() = runTest { + val vm = viewModelWithThrowingCreateBook(CancellationException("scope cancelled")) + vm.onTitleChanged("Dune") + + try { + vm.performSave(clearAfterSave = false) + fail("expected CancellationException to propagate") + } catch (e: CancellationException) { + // expected — a cancelled save is not a failed one. + } + } + + // --- double-tap guard --- + + @Test + fun `a second save while one is in flight does not create a second book`() = runTest { + viewModel.onTitleChanged("Dune") + + val first = launch { viewModel.performSave(clearAfterSave = false) } + val second = launch { viewModel.performSave(clearAfterSave = false) } + first.join() + second.join() + + val allBooks = mutableListOf() + bookRepository.observeAll().let { flow -> allBooks.addAll(flow.first()) } + assertEquals(1, allBooks.size) + // .first(), not .value — see the KDoc on AddBookViewModel.uiState. + assertEquals(1, viewModel.uiState.first().savedCount) + } + + // --- remembered shelf --- + + @Test + fun `saving to a shelf remembers it for next time`() = runTest { + viewModel.onTitleChanged("Dune") + viewModel.onShelfSelected(shelfId) + + viewModel.performSave(clearAfterSave = false) + + assertEquals(shelfId, settingsStore.lastShelfId.first()) + } + + @Test + fun `saving as Not shelved does not overwrite the remembered shelf`() = runTest { + settingsStore.setLastShelfId(shelfId) + viewModel.onTitleChanged("Dune") + viewModel.onShelfSelected(null) + + viewModel.performSave(clearAfterSave = false) + + assertEquals(shelfId, settingsStore.lastShelfId.first()) + } + + @Test + fun `the remembered shelf pre-selects the picker on a fresh form`() = runTest { + settingsStore.setLastShelfId(shelfId) + + val vm = AddBookViewModel( + bookRepository = bookRepository, + locationRepository = locationRepository, + settingsStore = settingsStore, + ) + + val preSelected = vm.uiState.first { it.selectedShelfId != null } + assertEquals(shelfId, preSelected.selectedShelfId) + } + + private fun viewModelWithThrowingCreateBook(toThrow: Throwable): AddBookViewModel = AddBookViewModel( + bookRepository = BookRepository(ThrowingUpsertBookDao(db.bookDao(), toThrow), context), + locationRepository = locationRepository, + settingsStore = settingsStore, + ) + + /** Delegates every query to the real DAO except [upsert], which [AddBookViewModel.performSave]'s createBook hits. */ + private class ThrowingUpsertBookDao( + private val delegate: BookDao, + private val toThrow: Throwable, + ) : BookDao by delegate { + override suspend fun upsert(book: BookEntity) = throw toThrow + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/nav/RoutesTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/nav/RoutesTest.kt new file mode 100644 index 0000000..faf6a64 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/ui/nav/RoutesTest.kt @@ -0,0 +1,77 @@ +package org.modg.bookshelf.ui.nav + +import android.net.Uri +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * [Routes.addBook] must percent-encode its args well enough to survive the + * query-string round trip Navigation Compose actually performs on a real + * device: it parses the built route as a [Uri] and extracts each `{arg}` + * placeholder via [Uri.getQueryParameter], which does standard percent- + * decoding (not `+`-as-space form-decoding) — so this test decodes the same + * way, via a real [Uri] under Robolectric, rather than re-implementing + * decoding by hand and only proving the two sides of one hand-rolled pair + * agree with each other. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class RoutesTest { + + private fun decode(route: String, arg: String): String? = + Uri.parse("scheme://host/$route").getQueryParameter(arg) + + @Test + fun `round-trips a title containing query-breaking characters`() { + val title = "Hänsel & Gretel / Part 1?" + val route = Routes.addBook(title = title) + + assertEquals(title, decode(route, Routes.ADD_BOOK_TITLE_ARG)) + } + + @Test + fun `round-trips a title containing a hash`() { + val title = "Chapter #3: The Return" + val route = Routes.addBook(title = title) + + assertEquals(title, decode(route, Routes.ADD_BOOK_TITLE_ARG)) + } + + @Test + fun `round-trips spaces and non-ASCII`() { + val title = "Le Petit Prince — a été traduit" + val route = Routes.addBook(title = title) + + assertEquals(title, decode(route, Routes.ADD_BOOK_TITLE_ARG)) + } + + @Test + fun `round-trips both title and isbn together`() { + val title = "Q&A: Life, the Universe / Everything?" + val isbn = "978-0-13-468599-1" + val route = Routes.addBook(title = title, isbn = isbn) + + assertEquals(title, decode(route, Routes.ADD_BOOK_TITLE_ARG)) + assertEquals(isbn, decode(route, Routes.ADD_BOOK_ISBN_ARG)) + } + + @Test + fun `omits absent args entirely rather than encoding a literal null`() { + assertEquals("add", Routes.addBook()) + assertEquals("add?title=${Uri.encode("Dune")}", Routes.addBook(title = "Dune")) + + val isbnOnlyRoute = Routes.addBook(isbn = "9780441013593") + assertNull(decode(isbnOnlyRoute, Routes.ADD_BOOK_TITLE_ARG)) + assertEquals("9780441013593", decode(isbnOnlyRoute, Routes.ADD_BOOK_ISBN_ARG)) + } + + @Test + fun `bare add route matches the ADD_BOOK pattern's base path`() { + assertEquals("add", Routes.addBook().substringBefore("?")) + assertEquals("add", Routes.ADD_BOOK.substringBefore("?")) + } +} diff --git a/app/app/src/test/java/org/modg/bookshelf/ui/screens/AddBookScreenPaparazziTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/screens/AddBookScreenPaparazziTest.kt new file mode 100644 index 0000000..69c0d20 --- /dev/null +++ b/app/app/src/test/java/org/modg/bookshelf/ui/screens/AddBookScreenPaparazziTest.kt @@ -0,0 +1,86 @@ +package org.modg.bookshelf.ui.screens + +import androidx.compose.runtime.Composable +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import org.junit.Rule +import org.junit.Test +import org.modg.bookshelf.ui.add.AddBookCallbacks +import org.modg.bookshelf.ui.add.AddBookContent +import org.modg.bookshelf.ui.add.AddBookUiState +import org.modg.bookshelf.ui.add.BookDraft +import org.modg.bookshelf.ui.scan.DuplicateStatus +import org.modg.bookshelf.ui.theme.BookshelfTheme + +/** + * SPEC gap: add-by-hand form for books with no barcode. Renders the REAL + * [AddBookContent] fed with static [AddBookUiState] — no hand-rolled lookalike + * (see [ScreenFixtures]'s KDoc for why a real [org.modg.bookshelf.AppContainer] + * can't drive this inside Paparazzi, and HANDOFF.md for why wave 6's Locations + * shortcut isn't repeated here). + */ +class AddBookScreenPaparazziTest { + + @get:Rule + val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6) + + @Test + fun addBookEmptyLight() = snapshotBoth("add-book-empty") { + AddBookContent(state = AddBookUiState(), callbacks = noopCallbacks()) + } + + @Test + fun addBookFilledWithDuplicateLight() = snapshotBoth("add-book-filled-duplicate") { + AddBookContent( + state = AddBookUiState( + draft = BookDraft( + title = "Dune", + subtitle = "", + authors = "Frank Herbert", + publisher = "Ace", + publishedDate = "1965", + pages = "412", + isbn = "9780441013593", + description = "A desert planet, a young man's destiny, and the spice that binds it all.", + ), + bookcases = ScreenFixtures.bookcases, + shelves = ScreenFixtures.shelves, + selectedShelfId = ScreenFixtures.topShelf.id, + duplicate = DuplicateStatus.AlreadyOwned(bookId = "b1", title = "Dune"), + ), + callbacks = noopCallbacks(), + ) + } + + @Test + fun addBookIsbnErrorLight() = snapshotBoth("add-book-isbn-error") { + AddBookContent( + state = AddBookUiState( + draft = BookDraft(title = "Old Pamphlet", isbn = "not-an-isbn"), + bookcases = ScreenFixtures.bookcases, + shelves = ScreenFixtures.shelves, + ), + callbacks = noopCallbacks(), + ) + } + + private fun noopCallbacks() = AddBookCallbacks( + onTitleChanged = {}, + onSubtitleChanged = {}, + onAuthorsChanged = {}, + onPublisherChanged = {}, + onPublishedDateChanged = {}, + onPagesChanged = {}, + onIsbnChanged = {}, + onDescriptionChanged = {}, + onShelfSelected = {}, + onSave = {}, + onSaveAndAddAnother = {}, + onBack = {}, + ) + + 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/LibraryScreenPaparazziTest.kt b/app/app/src/test/java/org/modg/bookshelf/ui/screens/LibraryScreenPaparazziTest.kt index 0b964e8..c907751 100644 --- 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 @@ -3,12 +3,16 @@ 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.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height 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.EditNote import androidx.compose.material.icons.outlined.QrCodeScanner import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Warehouse @@ -16,7 +20,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import app.cash.paparazzi.DeviceConfig @@ -28,6 +34,7 @@ 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.SecondaryButton import org.modg.bookshelf.ui.components.SyncStatus import org.modg.bookshelf.ui.components.SyncStatusBar import org.modg.bookshelf.ui.library.LibraryBookCard @@ -71,7 +78,11 @@ class LibraryScreenPaparazziTest { IconButton(onClick = {}) { Icon(Icons.Outlined.Settings, contentDescription = "Settings") } }, floatingActionButton = { - FloatingActionButton(onClick = {}) { Icon(Icons.Outlined.QrCodeScanner, contentDescription = "Scan a book") } + Column(horizontalAlignment = Alignment.End) { + SmallFloatingActionButton(onClick = {}) { Icon(Icons.Outlined.EditNote, contentDescription = "Add a book by hand") } + Spacer(modifier = Modifier.height(12.dp)) + FloatingActionButton(onClick = {}) { Icon(Icons.Outlined.QrCodeScanner, contentDescription = "Scan a book") } + } }, syncStatusBar = { SyncStatusBar(status = syncStatus, label = syncLabel) }, ) { innerPadding -> @@ -91,7 +102,12 @@ class LibraryScreenPaparazziTest { EmptyState( title = "Your shelves are empty", message = "Scan a barcode to add your first book.", - action = { PrimaryButton(text = "Scan a book", onClick = {}) }, + action = { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PrimaryButton(text = "Scan a book", onClick = {}) + SecondaryButton(text = "Add by hand", onClick = {}) + } + }, ) } else { LazyVerticalGrid( diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_AddBookScreenPaparazziTest_addBookEmptyLight_add-book-empty-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_AddBookScreenPaparazziTest_addBookEmptyLight_add-book-empty-dark.png new file mode 100644 index 0000000..95dea59 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_AddBookScreenPaparazziTest_addBookEmptyLight_add-book-empty-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_AddBookScreenPaparazziTest_addBookEmptyLight_add-book-empty-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_AddBookScreenPaparazziTest_addBookEmptyLight_add-book-empty-light.png new file mode 100644 index 0000000..1228abd Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_AddBookScreenPaparazziTest_addBookEmptyLight_add-book-empty-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_AddBookScreenPaparazziTest_addBookFilledWithDuplicateLight_add-book-filled-duplicate-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_AddBookScreenPaparazziTest_addBookFilledWithDuplicateLight_add-book-filled-duplicate-dark.png new file mode 100644 index 0000000..163c81f Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_AddBookScreenPaparazziTest_addBookFilledWithDuplicateLight_add-book-filled-duplicate-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_AddBookScreenPaparazziTest_addBookFilledWithDuplicateLight_add-book-filled-duplicate-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_AddBookScreenPaparazziTest_addBookFilledWithDuplicateLight_add-book-filled-duplicate-light.png new file mode 100644 index 0000000..2555efa Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_AddBookScreenPaparazziTest_addBookFilledWithDuplicateLight_add-book-filled-duplicate-light.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_AddBookScreenPaparazziTest_addBookIsbnErrorLight_add-book-isbn-error-dark.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_AddBookScreenPaparazziTest_addBookIsbnErrorLight_add-book-isbn-error-dark.png new file mode 100644 index 0000000..c0a7600 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_AddBookScreenPaparazziTest_addBookIsbnErrorLight_add-book-isbn-error-dark.png differ diff --git a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_AddBookScreenPaparazziTest_addBookIsbnErrorLight_add-book-isbn-error-light.png b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_AddBookScreenPaparazziTest_addBookIsbnErrorLight_add-book-isbn-error-light.png new file mode 100644 index 0000000..8860504 Binary files /dev/null and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_AddBookScreenPaparazziTest_addBookIsbnErrorLight_add-book-isbn-error-light.png differ 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 index ecd0f24..caf2737 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryEmptyLight_library-empty-dark.png 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 index 0a298ed..3b2e6d0 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryEmptyLight_library-empty-light.png 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 index 6b4131b..c0953a6 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryPopulatedLight_library-populated-dark.png 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 index b578d34..bcc75c3 100644 Binary files a/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryPopulatedLight_library-populated-light.png and b/app/app/src/test/snapshots/images/org.modg.bookshelf.ui.screens_LibraryScreenPaparazziTest_libraryPopulatedLight_library-populated-light.png differ diff --git a/logs/K-manual-fix.sid b/logs/K-manual-fix.sid new file mode 100644 index 0000000..e075c3a --- /dev/null +++ b/logs/K-manual-fix.sid @@ -0,0 +1 @@ +3eb4f3f4-1013-4838-91eb-d217251be382 diff --git a/logs/K-manual-fix.summary b/logs/K-manual-fix.summary new file mode 100644 index 0000000..c138d47 --- /dev/null +++ b/logs/K-manual-fix.summary @@ -0,0 +1,34 @@ +=== K-manual-fix attempts=1 quota_waits=0 hard_fails=0 === +cost=$7.268144399999997 turns=7 err=? +--- result (tail) --- +59-6` → `...01359-7`, and the corresponding expected `isbn10` value); added `Dispatchers.setMain(UnconfinedTestDispatcher())`/`resetMain()` in `@Before`/`@After`; added `@OptIn(ExperimentalCoroutinesApi::class)` (matches `ScannerControllerTest`'s existing convention, silences the opt-in warning); switched 5 direct `.value` reads to `.first()`. +- `app/app/src/main/java/org/modg/bookshelf/ui/add/AddBookViewModel.kt`: added a KDoc comment on `uiState` explaining the `WhileSubscribed` caveat for future readers — no behavioral change. +- Recorded and eyeballed the new Paparazzi goldens (`AddBookScreenPaparazziTest` ×3 states ×2 themes, `LibraryScreenPaparazziTest` re-recorded ×2 states ×2 themes) — all render correctly: form fields, ISBN error state, duplicate warning, the new satellite "Add by hand" FAB, and the empty-state secondary button, in both themes. + +Nothing else touched — the feature itself (screens, nav, routes, entry points) is exactly as the previous worker left it. + +### Gate output (verbatim tails) + +``` +./tasks/gw assembleDebug +BUILD SUCCESSFUL in 1m 43s +39 actionable tasks: 3 executed, 36 up-to-date + +./tasks/gw testDebugUnitTest +BUILD SUCCESSFUL in 1m 24s +34 actionable tasks: 1 executed, 33 up-to-date +``` +Summed from `TEST-*.xml`: **tests=241 skipped=2 failures=0 errors=0** (baseline was 205; up 36, zero failures/skips beyond the 2 pre-existing opt-in live tests). + +``` +./tasks/gw recordPaparazziDebug -> BUILD SUCCESSFUL in 3m 42s +./tasks/gw verifyPaparazziDebug +BUILD SUCCESSFUL in 48s +34 actionable tasks: 1 executed, 33 up-to-date +``` + +No Kotlin warnings on any touched file in the full build log (`grep "^w:"` empty). + +### Not committed + +Working tree only, as required — `git status` shows the modifications/new files, nothing staged or committed. diff --git a/logs/K-manual.sid b/logs/K-manual.sid new file mode 100644 index 0000000..217f282 --- /dev/null +++ b/logs/K-manual.sid @@ -0,0 +1 @@ +1f9304e1-ad80-456e-ab8c-3720b352905e diff --git a/logs/K-manual.summary b/logs/K-manual.summary new file mode 100644 index 0000000..e7bcf84 --- /dev/null +++ b/logs/K-manual.summary @@ -0,0 +1,4 @@ +=== K-manual attempts=1 quota_waits=0 hard_fails=0 === +cost=$5.051114400000003 turns=108 err=? +--- result (tail) --- +I'll stop polling now and wait for the background monitor to notify me when `testDebugUnitTest` completes. diff --git a/tasks/K-manual-fix.txt b/tasks/K-manual-fix.txt new file mode 100644 index 0000000..bf5fea1 --- /dev/null +++ b/tasks/K-manual-fix.txt @@ -0,0 +1,14 @@ +A previous worker implemented the task in ~/bookshelf/tasks/K-manual.txt and left the +working tree FAILING its acceptance gate. Read, in order: ~/bookshelf/docs/SPEC.md, +~/bookshelf/tasks/K-manual.txt (the original instructions — every constraint in it binds +you too), ~/bookshelf/logs/K-manual.summary (that worker's report), then the gate output at +~/bookshelf/logs/gate-K-manual.log. Lines starting "GATE:" and non-zero exits are the failures. + +Fix ONLY what makes the gate red. Do not redesign or re-implement the feature. The +test count must end ABOVE 205 with zero failures; do not delete or @Ignore tests to +get there. If a Paparazzi verify failed because snapshots were never recorded, record +them and LOOK at the PNGs before accepting them. + +Run in the FOREGROUND (never background a build): ./tasks/gw assembleDebug, +./tasks/gw testDebugUnitTest, ./tasks/gw verifyPaparazziDebug. Do not git commit/add/push. +Report what was wrong, what you changed, and the verbatim output tails.