feat(wheel): finish gate UX polish — bounced-back banner on unanswered prompts, screen-reader live region, ViewModel unit test

This commit is contained in:
null 2026-06-28 19:54:31 -05:00
parent 084a8a5391
commit 403be3939c
6 changed files with 287 additions and 61 deletions

View File

@ -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) ·

View File

@ -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 `<vector>` until commit **334cb07 "brand: update app icon"** which swapped it to a **`<bitmap>`** wrapper; `painterResource` routes any `.xml` drawable through the VectorDrawable loader, which throws on a `<bitmap>` 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 `<bitmap>` wraps; same pattern `LoadingState.kt:146` already uses); the `<bitmap>` 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 `<bitmap>`; 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-`<vector>` 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.

View File

@ -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,

View File

@ -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))
}
}
}

View File

@ -26,6 +26,14 @@ data class WheelSessionUiState(
val isLoading: Boolean = true,
val questions: List<Question> = 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<String?> = 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<String> = 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<WheelAnswerEntry>()
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)) }

View File

@ -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<List<WheelAnswerEntry>>()
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)
}
}