diff --git a/ClaudeQAPlan.md b/ClaudeQAPlan.md index fe66facc..d30ad360 100644 --- a/ClaudeQAPlan.md +++ b/ClaudeQAPlan.md @@ -847,6 +847,16 @@ open**. No duplicates; rate limiter (20/day, 100/week) doesn't drop legit ones. exit/loop/blank; (6) deep-link re-checks auth + couple membership + pairing + entitlement + target ownership + session status + existence — a non-member/logged-out/stale/unpaired open must NOT reach private content and must fail gracefully. +- **`qa/qa_push.js` is faithful to the PUSH, not the TRIGGER — assertion #1 needs ≥1 real in-app action per round.** + `qa_push.js` sends the FCM via admin (`messaging().send`), so it faithfully reproduces delivery + channel/copy + + cold-start launch + tap-routing (use it for the bulk of the type×state matrix and the `entrypoint_smoke.sh` smoke). + But it **bypasses the Cloud Function** — no `onMessageWritten`/`onGameSessionUpdate`/`onAnswerWritten`/`createDateMatch` + actually ran. So a `qa_push.js`-only round can **never** satisfy assertion #1 (**trigger fires correctly**): a broken + or un-deployed trigger (Firestore-path change, deploy regression, rules change) is **invisible** to synthetic pushes. + Each round, drive **≥1 real in-app partner action** (send a chat, finish a game, answer the daily Q) and confirm the + matching push lands on the partner. (UI-automation tip: the chat composer's send button is the rightmost control in + the composer row, content-desc `Send`; if `uiautomator` taps mis-fire, verify the action via admin read — the new + message/answer doc exists `enc:v1:` — rather than claiming the trigger from a synthetic push.) - **Inventory (type → Cloud-Function trigger → recipient → destination)** — verify each; mark any unimplemented type `not implemented→Future.md` (don't count as pass): `chat_message`(onMessageWritten → partner → conversation; foreground→chat-head bubble) · diff --git a/ClaudeReport.md b/ClaudeReport.md index ead10fdd..11644ff3 100644 --- a/ClaudeReport.md +++ b/ClaudeReport.md @@ -18,7 +18,8 @@ > to the archived-ID line below (full detail stays in git history). See **Report hygiene** in `ClaudeQAPlan.md`. ## Run-state (current) -- **R18b (2026-06-28) — Pass E full live re-run (user: "run ClaudeQAPLan pass E") — ✅ CLEAN, 0 P0/P1, 0 FATAL.** Both emulators online (5554=QA, 5556=Sam, paired `Xal3Kw3gjSdn0niERYKJ`, both free), fresh FCM tokens (1 each). **Cold-start crash-triage smoke 6/6 on BOTH** (`qa/entrypoint_smoke.sh`: launcher + 5 push types `am kill`→real push→shade-tap→opens&stays, 0 fail/0 blocked) — the shared splash/onCreate path is clean. **Routing (background→tap, landed-screen verified):** 7 types received on Sam + 3 on QA (both-client) — chat→exact conversation, partner_answered & daily_question→Today, started_game & completed_part(tot)→game screen, finished_game(wheel)→per-session results (completed→results, not a dead active session), date_match→Your Matches; every tap correct destination + app alive + 0 FATAL. **Foreground:** partner_started_game→in-app banner (Join/dismiss) ✅; chat_message→draggable chat-head bubble ✅ (verified via real open→back→Home→send + distinct conv id; `conversation_id=main` suppression on a process-death-restored back stack is **by-design read-suppression** via `ActiveThreadMonitor`, clears on normal back-nav — not a defect). **Malformed/stale (all graceful, 0 FATAL):** unknown type→no nav/no crash; chat w/o conversation_id→Messages inbox; started_game w/o game_type→Play hub; finished_game w/ deleted session→graceful waiting state w/ escape. **Payload privacy (P0) clean** — code audit of all 6 senders (`onMessageWritten`/`onGameSessionUpdate`(+part-finished)/`onAnswerWritten`/`onAnswerRevealed`/`createDateMatch`/`onCoupleLeave`): `data` carries only routing IDs + optional public avatar URL, titles use display name only, bodies static; **no message/answer/date/swipe content, no keys/codes/phrases**; at-rest D1 cross-check — latest 6 `conversations/main/messages` all `enc:v1:`. **NOT re-run this round:** real in-app `onMessageWritten` send (UI-automation thrash on the composer send button) → carried from R18 live (exact copy, no content) + this round's code audit + at-rest D1; **Doze/battery/App-Standby = `blocked→needs-device`** (emulators can't enter those states — run on a physical device before store push). **No app-code changes** (pure QA round); only `ClaudeReport.md` + `ClaudeQACoverage.md` touched (user commits). Confirmed Navigation Compose **restores the back stack across process death** (launcher cold-start lands on the last sub-screen) — expected Android behavior, and the source of the bubble-suppression artifact above. NEXT: real-trigger live re-drive when convenient; physical-device Doze gate; continue other passes. +- **R18b (2026-06-28) — FEATURE: games must be fully answered before finishing (user: "if a user skips a question it makes the user go back and answer it before the game is over … for all games").** Grounding found the four Play-hub games use different models and **only Spin the Wheel** let a player finish with blanks (explicit Skip, `Next` advanced when blank, `End session` submitted the rest as "Skipped", and it's the only game with text boxes); **This or That / Desire Sync / How Well already require a pick to advance** (verified by code — `select()` needs an option / `commitAnswer` guard + `enabled = hasSelection`). Per user decision **"Hybrid"**: implement skip-then-must-complete on the Wheel; leave the other three (no forced skip affordance). **Wheel change (`WheelSessionViewModel.kt` + `WheelSessionScreen.kt`):** answers are now an index-keyed nullable list; `skip()`/blank-`next()` leave a slot `null`; the new **`attemptFinish()` gate** submits only when no slot is `null`, else bounces to the first unanswered prompt and shows a "N questions left — answer them to finish" banner (a11y `liveRegion`); `End session`→`Finish now` (gated); enforces non-empty text + ≥1 choice via the existing `hasValidSelection()`. Category-picker copy updated. **Verified:** build + **205→ unit tests green incl. 3 new `WheelSessionViewModelTest`** (gaps→bounce/no-submit; all-answered→submit with no "Skipped"; completion-walk). **Live (emulator-5554, fresh wheel):** `Finish now` with all blank → banner "10 questions left" + stayed on Q1 (no submit); answered Q1 + `Finish` → jumped to Q2, banner "9 questions left" — gate + banner + walk-forward + text-box enforcement all confirmed; 0 FATAL. Adjacent checks (no change): multi_choice has no `minSelections` (≥1 is correct); Daily Question already gated (`canSubmit`); reveal renders legacy "Skipped" as `display ?: "—"` (no crash). **Test-data notes:** cleared a stale stuck wheel session via the in-app reveal→`markUserComplete` path (admin write to flip it was **classifier-denied**, not worked around); live testing then created one new active wheel session (net-neutral) which blocked live-opening the other 3 games — those are verified by code (unchanged) + observed during Pass E. Uncommitted (user commits): `WheelSessionViewModel.kt`, `WheelSessionScreen.kt`, `CategoryPickerScreen.kt`, `WheelSessionViewModelTest.kt`, `ClaudeReport.md`. +- **R18b (2026-06-28) — Pass E full live re-run (user: "run ClaudeQAPLan pass E") — ✅ CLEAN, 0 P0/P1, 0 FATAL.** Both emulators online (5554=QA, 5556=Sam, paired `Xal3Kw3gjSdn0niERYKJ`, both free), fresh FCM tokens (1 each). **Cold-start crash-triage smoke 6/6 on BOTH** (`qa/entrypoint_smoke.sh`: launcher + 5 push types `am kill`→real push→shade-tap→opens&stays, 0 fail/0 blocked) — the shared splash/onCreate path is clean. **Routing (background→tap, landed-screen verified):** 7 types received on Sam + 3 on QA (both-client) — chat→exact conversation, partner_answered & daily_question→Today, started_game & completed_part(tot)→game screen, finished_game(wheel)→per-session results (completed→results, not a dead active session), date_match→Your Matches; every tap correct destination + app alive + 0 FATAL. **Foreground:** partner_started_game→in-app banner (Join/dismiss) ✅; chat_message→draggable chat-head bubble ✅ (verified via real open→back→Home→send + distinct conv id; `conversation_id=main` suppression on a process-death-restored back stack is **by-design read-suppression** via `ActiveThreadMonitor`, clears on normal back-nav — not a defect). **Malformed/stale (all graceful, 0 FATAL):** unknown type→no nav/no crash; chat w/o conversation_id→Messages inbox; started_game w/o game_type→Play hub; finished_game w/ deleted session→graceful waiting state w/ escape. **Payload privacy (P0) clean** — code audit of all 6 senders (`onMessageWritten`/`onGameSessionUpdate`(+part-finished)/`onAnswerWritten`/`onAnswerRevealed`/`createDateMatch`/`onCoupleLeave`): `data` carries only routing IDs + optional public avatar URL, titles use display name only, bodies static; **no message/answer/date/swipe content, no keys/codes/phrases**; at-rest D1 cross-check — latest 6 `conversations/main/messages` all `enc:v1:`. **NOT re-run this round:** real in-app `onMessageWritten` send (UI-automation thrash on the composer send button) → carried from R18 live (exact copy, no content) + this round's code audit + at-rest D1; **Doze/battery/App-Standby = `blocked→needs-device`** (emulators can't enter those states — run on a physical device before store push). **No app-code changes** (pure QA round); touched `ClaudeReport.md` + `ClaudeQACoverage.md` + `ClaudeQAPlan.md` (added a Pass-E guard: `qa_push.js` reproduces the *push* but bypasses the Cloud Function *trigger*, so assertion #1 "trigger fires" needs ≥1 real in-app action per round) (user commits). Confirmed Navigation Compose **restores the back stack across process death** (launcher cold-start lands on the last sub-screen) — expected Android behavior, and the source of the bubble-suppression artifact above. NEXT: real-trigger live re-drive when convenient; physical-device Doze gate; continue other passes. - **R18b (2026-06-28) — Future.md review → found+fixed a P0 (user: "review Future.md and do fixes if needed. verify bugs and why").** **O-ONBOARD-001 (P0) — onboarding CRASHES on the final slide for EVERY fresh install** (and the login/signup screen too). **Verified live before/after on `emulator-5558` (fresh, API 34):** old build → onboarding slide-3 `CtaSlide` → `FATAL EXCEPTION: java.lang.IllegalArgumentException: Only VectorDrawables and rasterized asset types are supported` at `PainterResources…loadVectorResource` ← `OnboardingScreen.kt:246`; fixed build → CtaSlide renders the logo + "Create account", and the signup screen (AuthLogoMark) renders too — full onboarding→signup reachable, 0 FATAL. **Why (root cause, git-confirmed):** `ic_launcher_foreground.xml` was a `` until commit **334cb07 "brand: update app icon"** which swapped it to a **``** wrapper; `painterResource` routes any `.xml` drawable through the VectorDrawable loader, which throws on a `` root. The two Compose call sites — `OnboardingScreen.kt` `CtaSlide` + `AuthVisuals.kt` `AuthLogoMark` — weren't updated. **Regression invisible to recurring QA** because 5554/5556 are past onboarding + logged-in (signed up before 334cb07); every fresh install since crashes. (Future.md's root-cause guess — background/aapt quirk — was wrong.) **Fix:** both sites now `painterResource(R.drawable.closer_launcher_foreground)` (the raster the `` wraps; same pattern `LoadingState.kt:146` already uses); the `` XML stays for the real adaptive launcher icon. Scanned the whole app — **no other `painterResource`-on-non-vector-XML remains** (only `ic_launcher_foreground`/`_monochrome` are ``; monochrome isn't used via painterResource). Also fixed the remaining BucketList **add-FAB** hardcoded `Color(0xFFB98AF4)` → `MaterialTheme.colorScheme.primary` (closes the Future.md "BucketList mixed dark/light" item — dialog was R16, FAB was the leftover; verified live light). **Build clean; 205 unit + 24 functions green; all 3 emulators on the fixed APK.** **Regression guard ADDED + proven:** `scripts/painter-xml-scan.sh` flags any `painterResource(R.drawable.X)` where `X` is a non-`` XML drawable (the exact crash class); demonstrated it catches the bug when reintroduced (exit 1) and passes clean on the fix (exit 0); wired into the plan's cheap-gates (step 3). Uncommitted (user commits): `OnboardingScreen.kt`, `AuthVisuals.kt`, `BucketListScreen.kt`, `scripts/painter-xml-scan.sh`, `ClaudeQAPlan.md`, `Future.md`, `ClaudeReport.md`, `ClaudeQACoverage.md`. NEXT: prune O-ONBOARD-001 after 1 confirm; the instrumented onboarding→signup smoke (androidTest, currently 0) remains a `Future.md` idea (would have caught this too). - **R18 (2026-06-28) — continuing full run (user: "why are you stopping?" → don't hand back at checkpoints).** Both emulators online (5554=Dark, 5556=Light, both reset to Device-default after testing); package is `closer.app` (launcher `closer.app/app.closer.MainActivity`). **C-DARKART-002 FIXED + verified live across all 4 theme/art states** (see Severity-board R18 note + the issue row): `MainActivity` now drives `AppCompatDelegate.setDefaultNightMode` from `ThemeMode` (sync initial read → no flicker loop; `LaunchedEffect` for runtime toggles), so every `painterResource` + BrandIllustration follows the in-app theme via the real Configuration uiMode. The previously-broken **pack-art banners now render DARK** in decoupled in-app-Dark + system-light, and the Today hero does too; symmetric in-app-Light + system-dark → light; both coupled states correct. **C-DARKART-001 re-confirmed.** The test-suite gate also caught **TEST-002** (flaky `MemoryCapsuleGenerator` determinism test — un-injected `System.currentTimeMillis()` clock violated the documented "pure" contract; **no production caller yet** so zero runtime impact, but it intermittently reddened the suite) → fixed by injecting `createdAtMillis`. **Build clean; 205 unit + 24 functions green.** Uncommitted (user commits): `MainActivity.kt`, `MemoryCapsuleGenerator.kt` (+ its test), `ClaudeReport.md`. DONE this round: **Pass A ✅ / B ✅ / E ✅ / L ✅ / P ✅** + **M-001 confirmed** (recommend prune). NEXT: prune C-DARKART-002 / TEST-002 / P-GRAMMAR-001 / M-001 after this confirm round; resolve **O-AGE-001** (P2 pre-ship age gate — product call); P3 backlogs (BRAND-DARK-COVERAGE, BRAND-ICON-CUSTOM, C-ORIENT-001); optional deeper re-runs of F/G/I (last full sweep R12). Board: **0 open P0/P1 · 1 open P2 (O-AGE-001) · 3 open P3**. **Pass A ✅ (R18, live, Sam free on 5556 — both members confirmed free via admin read):** premium gate enforced across **two distinct surfaces** — Desire Sync (game) → Paywall, premium **Boundaries** pack → Paywall; negative control: **Mixed** "Communication" pack opens and a **free prompt is accessible** (answer composer shown), so the gate isn't over-broad; **Free** filter shows a graceful "Nothing in free yet" empty state (catalog note: no fully-free packs). Paywall billing plans don't load on the non-GMS emulator ("Couldn't load plans / Try again") — expected Pass K env limit, degrades gracefully (no crash). - **R17 (2026-06-28) — continuing full run (user: "complete full run, don't stop").** Both emulators on R16 build (5554=Dark, 5556=Light); HEAD `8b7bbc2` + working-tree fixes. **Theme fixes confirmed LIVE (dark, 5554):** C-THEME-005 (Wheel-History lock → surfaceVariant/primary), C-THEME-008/009 (Date-Match heart→primaryContainer + count badge→error) — joining 001/002 from R16. **NEW finding C-DARKART-002 (P2):** dark-variant art doesn't render in the decoupled in-app-Dark + system-light state — pack art (`QuestionPackLibraryScreen:223` via `packArtworkRes`) + ~7 literal `painterResource` sites resolve `-night` off SYSTEM uiMode, not the in-app theme; **proven live** (in-app-Dark + system `auto` → light pack art; system night=yes → correct dark art). The BRAND-DARK-COVERAGE batch art is correct but only shows under system-night. **Pass D1 at-rest = CLEAN (admin read R17):** messages `text`, `lastMessagePreview`, Memory Lane capsule content+title, **all 4 game answers (this_or_that/desire_sync/how_well/wheel) + date_swipe `action`** all `enc:v1:`; only metadata in clear. **Pass D3 = CLEAN (live raw-API R17):** minted non-member token → couple doc / messages / capsules / desire_sync reads + premium self-grant all **DENIED 403** (`scratchpad/d3_negative.js`). **C-DARKART-002 fully diagnosed** (routing through BrandIllustration is insufficient — `createConfigurationContext` doesn't resolve `-night` for these resources; recommended fix = sync config uiMode to in-app theme; my probe edit reverted; tree clean; build+units green; theme-scan CRIT still 0). NEXT (R18): **C-DARKART-002 fix** (uiMode-sync, architectural) + re-verify C-DARKART-001 holds; **M-001** quiet-hours backgrounded-push re-test → prune; live-confirm C-THEME-004 + light-side spot-check; then Pass A (premium gate) / B (a game) / E (full notif) / L / P. Cornerstones D1+D3 ✅ this round. diff --git a/app/src/main/java/app/closer/ui/wheel/CategoryPickerScreen.kt b/app/src/main/java/app/closer/ui/wheel/CategoryPickerScreen.kt index 9944ae98..16136a8d 100644 --- a/app/src/main/java/app/closer/ui/wheel/CategoryPickerScreen.kt +++ b/app/src/main/java/app/closer/ui/wheel/CategoryPickerScreen.kt @@ -229,7 +229,7 @@ private fun WheelPickerHeader( overflow = TextOverflow.Ellipsis ) Text( - text = "You can skip, continue, or end whenever the moment feels complete.", + text = "Skip and come back to any prompt — you'll just answer them all before the reveal.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, diff --git a/app/src/main/java/app/closer/ui/wheel/WheelSessionScreen.kt b/app/src/main/java/app/closer/ui/wheel/WheelSessionScreen.kt index e6170faa..48e7504e 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelSessionScreen.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelSessionScreen.kt @@ -49,6 +49,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -83,7 +86,7 @@ fun WheelSessionScreen( onWrittenTextChanged = viewModel::onWrittenTextChanged, onNext = viewModel::next, onSkip = viewModel::skip, - onEnd = viewModel::endEarly + onEnd = viewModel::attemptFinish ) } @@ -217,8 +220,32 @@ private fun WheelSessionContent( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp) ) { + // Finish gate: once a finish attempt finds unanswered prompts, the player is bounced + // back here. The banner explains why and announces it to screen readers. + if (state.showCompletionPrompt && state.unansweredCount > 0) { + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier + .fillMaxWidth() + .semantics { liveRegion = LiveRegionMode.Polite } + ) { + Text( + text = if (state.unansweredCount == 1) { + "1 question left — answer it to finish." + } else { + "${state.unansweredCount} questions left — answer them to finish." + }, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } Button( - onClick = onNext, + // In completion mode the primary CTA runs the finish gate (walking the player + // gap → gap); otherwise it advances, and on the last prompt also runs the gate. + onClick = if (state.showCompletionPrompt) onEnd else onNext, modifier = Modifier .fillMaxWidth() .heightIn(min = 56.dp), @@ -226,7 +253,7 @@ private fun WheelSessionContent( colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF56306F)) ) { Text( - text = if (current + 1 >= total) "Finish" else "Next question", + text = if (state.showCompletionPrompt || current + 1 >= total) "Finish" else "Next question", color = Color.White ) } @@ -249,7 +276,7 @@ private fun WheelSessionContent( .weight(1f) .heightIn(min = 48.dp) ) { - Text("End session", color = Color(0xFF9B8AA6)) + Text("Finish now", color = Color(0xFF9B8AA6)) } } } diff --git a/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt b/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt index 2c03f16e..8921f04f 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt @@ -26,6 +26,14 @@ data class WheelSessionUiState( val isLoading: Boolean = true, val questions: List = emptyList(), val currentIndex: Int = 0, + /** + * This player's answer per prompt, index-aligned to [questions]; `null` = not yet answered + * (skipped or never reached). The session can't be finished while any entry is still `null` + * (see [WheelSessionViewModel.attemptFinish]). + */ + val answers: List = emptyList(), + /** True once a finish attempt has found unanswered prompts and bounced the player back. */ + val showCompletionPrompt: Boolean = false, val skippedCount: Int = 0, val answeredCount: Int = 0, val categoryName: String = "", @@ -34,7 +42,10 @@ data class WheelSessionUiState( val selectedOptionIds: List = emptyList(), val selectedScaleValue: Int = 3, val writtenText: String = "" -) +) { + /** How many prompts are still unanswered — drives the "N left to finish" banner. */ + val unansweredCount: Int get() = answers.count { it == null } +} @HiltViewModel class WheelSessionViewModel @Inject constructor( @@ -53,7 +64,6 @@ class WheelSessionViewModel @Inject constructor( private var coupleId: String? = null private var userId: String? = null - private val myAnswers = mutableListOf() private var submitting = false init { @@ -109,6 +119,7 @@ class WheelSessionViewModel @Inject constructor( it.copy( isLoading = false, questions = questions, + answers = List(questions.size) { null }, categoryName = categoryName, selectedScaleValue = defaultScaleValue(questions.firstOrNull()) ) @@ -153,64 +164,76 @@ class WheelSessionViewModel @Inject constructor( _uiState.update { it.copy(writtenText = text) } } + /** + * Primary action. Saves the current prompt's answer (or leaves it blank), then advances — or, + * on the last prompt, runs the [attemptFinish] completion gate. + */ fun next() { + saveCurrent() val state = _uiState.value - val question = state.questions.getOrNull(state.currentIndex) ?: return - val answered = hasValidSelection(state) - recordAnswer(question.id, if (answered) displayFor(question, state) else SKIPPED) - advance(answered) - } - - fun skip() { - val state = _uiState.value - val question = state.questions.getOrNull(state.currentIndex) ?: return - recordAnswer(question.id, SKIPPED) - advance(answered = false) - } - - fun endEarly() { - val state = _uiState.value - val question = state.questions.getOrNull(state.currentIndex) - if (question != null) { - val answered = hasValidSelection(state) - recordAnswer(question.id, if (answered) displayFor(question, state) else SKIPPED) - } - // Mark any prompts we never reached as skipped so both partners' answer lists - // line up with the shared question set. - for (i in myAnswers.size until state.questions.size) { - recordAnswer(state.questions[i].id, SKIPPED) - } - submitAndFinish() - } - - private fun advance(answered: Boolean) { - val state = _uiState.value - val nextIndex = state.currentIndex + 1 - if (nextIndex >= state.questions.size) { - _uiState.update { - it.copy( - answeredCount = it.answeredCount + if (answered) 1 else 0, - skippedCount = it.skippedCount + if (answered) 0 else 1 - ) - } - submitAndFinish() + if (state.currentIndex >= state.questions.lastIndex) { + attemptFinish() } else { - val nextQuestion = state.questions.getOrNull(nextIndex) - _uiState.update { - it.copy( - currentIndex = nextIndex, - answeredCount = it.answeredCount + if (answered) 1 else 0, - skippedCount = it.skippedCount + if (answered) 0 else 1, - selectedOptionIds = emptyList(), - selectedScaleValue = defaultScaleValue(nextQuestion), - writtenText = "" - ) - } + moveTo(state.currentIndex + 1) } } - private fun recordAnswer(questionId: String, display: String) { - myAnswers.add(WheelAnswerEntry(questionId = questionId, display = display)) + /** Defer the current prompt: leave it explicitly unanswered and move on. It still has to be + * answered before the session can finish (the [attemptFinish] gate bounces back to it). */ + fun skip() { + setAnswerAt(_uiState.value.currentIndex, null) + val state = _uiState.value + if (state.currentIndex >= state.questions.lastIndex) { + attemptFinish() + } else { + moveTo(state.currentIndex + 1) + } + } + + /** + * Completion gate. Saves the current prompt, then submits **only if every prompt is answered**. + * If any are still blank, bounce to the first unanswered one and show the "N left to finish" + * banner so the player can never finish (or end early) with gaps. + */ + fun attemptFinish() { + saveCurrent() + val gap = _uiState.value.answers.indexOfFirst { it == null } + if (gap >= 0) { + _uiState.update { it.copy(showCompletionPrompt = true) } + moveTo(gap) + } else { + submitAndFinish() + } + } + + /** Persist the current editor's value into the answer slot (null when nothing valid is entered). */ + private fun saveCurrent() { + val state = _uiState.value + val question = state.questions.getOrNull(state.currentIndex) ?: return + val display = if (hasValidSelection(state)) displayFor(question, state) else null + setAnswerAt(state.currentIndex, display) + } + + private fun setAnswerAt(index: Int, display: String?) { + _uiState.update { + if (index !in it.answers.indices) it + else it.copy(answers = it.answers.toMutableList().also { list -> list[index] = display }) + } + } + + /** Move to [index], resetting the editor. Unanswered slots are null, so a blank editor is correct. */ + private fun moveTo(index: Int) { + val nextQuestion = _uiState.value.questions.getOrNull(index) + _uiState.update { + it.copy( + currentIndex = index, + selectedOptionIds = emptyList(), + selectedScaleValue = defaultScaleValue(nextQuestion), + writtenText = "", + answeredCount = it.answers.count { a -> a != null }, + skippedCount = it.answers.count { a -> a == null } + ) + } } private fun submitAndFinish() { @@ -224,6 +247,11 @@ class WheelSessionViewModel @Inject constructor( } val state = _uiState.value val questionRefs = state.questions.map { WheelQuestionRef(it.id, it.text) } + // By the time we get here the completion gate guarantees no nulls; SKIPPED is only a + // defensive fallback so the entry list always lines up with the shared question set. + val entries = state.questions.mapIndexed { i, q -> + WheelAnswerEntry(questionId = q.id, display = state.answers.getOrNull(i) ?: SKIPPED) + } viewModelScope.launch { runCatching { answerDataSource.submitAnswers( @@ -232,7 +260,7 @@ class WheelSessionViewModel @Inject constructor( userId = uid, categoryName = state.categoryName, questions = questionRefs, - answers = myAnswers.toList() + answers = entries ) }.onFailure { Log.w(TAG, "Could not submit wheel answers", it) } _uiState.update { it.copy(navigateTo = AppRoute.wheelComplete(sessionId)) } diff --git a/app/src/test/java/app/closer/ui/wheel/WheelSessionViewModelTest.kt b/app/src/test/java/app/closer/ui/wheel/WheelSessionViewModelTest.kt new file mode 100644 index 00000000..8b1e6573 --- /dev/null +++ b/app/src/test/java/app/closer/ui/wheel/WheelSessionViewModelTest.kt @@ -0,0 +1,160 @@ +package app.closer.ui.wheel + +import androidx.lifecycle.SavedStateHandle +import app.closer.core.navigation.AppRoute +import app.closer.data.remote.FirestoreWheelAnswerDataSource +import app.closer.data.remote.WheelAnswerEntry +import app.closer.domain.model.ChoiceAnswerConfig +import app.closer.domain.model.ChoiceAnswerConfigImpl +import app.closer.domain.model.ChoiceOption +import app.closer.domain.model.Couple +import app.closer.domain.model.Question +import app.closer.domain.model.QuestionSession +import app.closer.domain.model.WrittenAnswerConfig +import app.closer.domain.model.WrittenAnswerConfigImpl +import app.closer.domain.repository.QuestionRepository +import app.closer.domain.usecase.GameSessionManager +import app.closer.notifications.ActiveGameSessionMonitor +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +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.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Verifies the Spin-the-Wheel completion gate: a player may skip/leave a prompt blank, but the + * session can NOT be finished (submitted) until every prompt is answered — a finish attempt with + * gaps bounces back to the first unanswered prompt and shows the completion banner. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class WheelSessionViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private val sessionStore: LocalWheelSessionStore = mockk(relaxed = true) + private val repository: QuestionRepository = mockk() + private val gameSessionManager: GameSessionManager = mockk() + private val answerDataSource: FirestoreWheelAnswerDataSource = mockk(relaxed = true) + private val monitor: ActiveGameSessionMonitor = mockk(relaxed = true) + + private val writtenQ = Question( + id = "q1", text = "Written prompt", type = "written", + answerConfig = WrittenAnswerConfigImpl(config = WrittenAnswerConfig()) + ) + private val choiceQ = Question( + id = "q2", text = "Choice prompt", type = "single_choice", + answerConfig = ChoiceAnswerConfigImpl( + config = ChoiceAnswerConfig( + options = listOf(ChoiceOption("optA", "Option A"), ChoiceOption("optB", "Option B")) + ) + ) + ) + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + every { gameSessionManager.currentUserId } returns "uidA" + coEvery { gameSessionManager.getCoupleForUser("uidA") } returns Couple(id = "couple1") + coEvery { gameSessionManager.getActiveSession("couple1") } returns + QuestionSession(questionIds = listOf("q1", "q2")) + coEvery { answerDataSource.getDoc("couple1", "sess1") } returns null + coEvery { repository.getQuestionById("q1") } returns writtenQ + coEvery { repository.getQuestionById("q2") } returns choiceQ + coEvery { repository.getCategoryById(any()) } returns null + } + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun createViewModel() = WheelSessionViewModel( + savedStateHandle = SavedStateHandle(mapOf("sessionId" to "sess1")), + sessionStore = sessionStore, + repository = repository, + gameSessionManager = gameSessionManager, + answerDataSource = answerDataSource, + activeGameSessionMonitor = monitor + ) + + @Test + fun `finish with unanswered prompts bounces to first gap and does not submit`() = runTest(dispatcher) { + val vm = createViewModel() + advanceUntilIdle() + assertEquals(2, vm.uiState.value.questions.size) + assertEquals(2, vm.uiState.value.unansweredCount) + + // Leave the written prompt blank, advance, leave the choice blank, then try to finish. + vm.next() + assertEquals(1, vm.uiState.value.currentIndex) + vm.attemptFinish() + advanceUntilIdle() + + assertTrue(vm.uiState.value.showCompletionPrompt) + assertEquals(0, vm.uiState.value.currentIndex) // bounced to the first unanswered prompt + assertEquals(2, vm.uiState.value.unansweredCount) + assertNull(vm.uiState.value.navigateTo) // not finished + coVerify(exactly = 0) { + answerDataSource.submitAnswers(any(), any(), any(), any(), any(), any()) + } + } + + @Test + fun `finishing with every prompt answered submits with no skipped entries`() = runTest(dispatcher) { + val vm = createViewModel() + advanceUntilIdle() + + vm.onWrittenTextChanged("hello") + vm.next() // saves q1 = "hello", advances to the choice prompt + assertEquals(1, vm.uiState.value.currentIndex) + vm.selectOption("optA") + vm.next() // last prompt -> runs the gate -> submits + advanceUntilIdle() + + val answersSlot = slot>() + coVerify(exactly = 1) { + answerDataSource.submitAnswers(any(), any(), any(), any(), any(), capture(answersSlot)) + } + assertEquals(listOf("hello", "Option A"), answersSlot.captured.map { it.display }) + assertFalse(answersSlot.captured.any { it.display == "Skipped" }) + assertEquals(AppRoute.wheelComplete("sess1"), vm.uiState.value.navigateTo) + } + + @Test + fun `completion mode walks the player through every gap before submitting`() = runTest(dispatcher) { + val vm = createViewModel() + advanceUntilIdle() + + // Skip both prompts, then try to finish -> bounced back to the first gap. + vm.skip() + vm.attemptFinish() + advanceUntilIdle() + assertEquals(0, vm.uiState.value.currentIndex) + assertTrue(vm.uiState.value.showCompletionPrompt) + + // Answer the first gap; finishing now jumps to the next gap (not a submit). + vm.onWrittenTextChanged("answer one") + vm.attemptFinish() + assertEquals(1, vm.uiState.value.currentIndex) + assertNull(vm.uiState.value.navigateTo) + + // Answer the last gap; now finishing actually submits. + vm.selectOption("optB") + vm.attemptFinish() + advanceUntilIdle() + assertEquals(0, vm.uiState.value.unansweredCount) + assertEquals(AppRoute.wheelComplete("sess1"), vm.uiState.value.navigateTo) + } +}