feat(wheel): finish gate UX polish — bounced-back banner on unanswered prompts, screen-reader live region, ViewModel unit test
This commit is contained in:
parent
084a8a5391
commit
403be3939c
|
|
@ -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) ·
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue