diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9741227..4714130 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -19,8 +19,6 @@ android { versionCode = 7 versionName = "0.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - // The app ships Vietnamese-only copy; trim library translations. - resourceConfigurations += listOf("vi") } sourceSets { @@ -33,6 +31,10 @@ android { } androidResources { + // The app ships Vietnamese-only copy; trim library translations. + // `resourceConfigurations` (defaultConfig) is deprecated for locales + // in AGP 8.13 in favor of this (L11). + localeFilters += listOf("vi") // Keep only the audio/ subtree from the mounted web/static dir. // Pattern extends AAPT's default ignore list (hidden files, VCS dirs). ignoreAssetsPattern = @@ -72,6 +74,17 @@ android { buildFeatures { compose = true } + + testOptions { + unitTests { + // L4 added android.util.Log calls in the persistence-failure + // fallback paths; the stock android.jar stub throws on any + // unmocked call, which would crash those JVM unit tests without + // pulling in Robolectric. Default-value stubbing is enough here + // since no test asserts on the logged message itself. + isReturnDefaultValues = true + } + } } dependencies { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8ae10b2..38b454a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -35,6 +35,8 @@ diff --git a/android/app/src/main/java/com/miti99/loto/LotoApplication.kt b/android/app/src/main/java/com/miti99/loto/LotoApplication.kt index 912c936..852f1f7 100644 --- a/android/app/src/main/java/com/miti99/loto/LotoApplication.kt +++ b/android/app/src/main/java/com/miti99/loto/LotoApplication.kt @@ -9,6 +9,7 @@ import com.miti99.loto.audio.ExoVoicePlayer import com.miti99.loto.audio.Voice import com.miti99.loto.audio.VoiceCatalog import com.miti99.loto.audio.VoicePlayerApi +import com.miti99.loto.audio.VoicePlayerHolder import com.miti99.loto.settings.Settings import com.miti99.loto.settings.SettingsRepository import com.miti99.loto.state.GameStateRepository @@ -18,6 +19,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -54,9 +56,27 @@ class LotoApplication : Application() { ) } - /** App-scoped settings snapshot every ViewModel reads from. */ - val settingsState: StateFlow by lazy { + /** + * Sole `stateIn` collector over `settingsRepository.settingsFlow`; null = + * the first DataStore read has not resolved yet. `settingsState` below is + * *derived* from this rather than collecting `settingsFlow` a second, + * independent time — two independent collectors of the same upstream + * cannot be trusted to update in step with each other, which is exactly + * what let the M2 gate desync from the settings value it was supposed to + * guard (a consumer needing to correlate "loaded" with "the loaded + * value" — PlayerBoardViewModel — reads this field directly instead of + * `settingsState` + a separate loaded flag). + */ + val settingsOrNull: StateFlow by lazy { settingsRepository.settingsFlow + .map { it } + .stateIn(appScope, SharingStarted.Eagerly, null) + } + + /** App-scoped settings snapshot every other ViewModel/Compose consumer reads from. */ + val settingsState: StateFlow by lazy { + settingsOrNull + .map { it ?: settingsRepository.defaults } .stateIn(appScope, SharingStarted.Eagerly, settingsRepository.defaults) } @@ -68,15 +88,34 @@ class LotoApplication : Application() { MasterStore(gameStateRepository, appScope) } - /** Built on the main thread (ExoPlayer requirement). */ - val voicePlayer: VoicePlayerApi by lazy { - ExoVoicePlayer(this).also { it.voiceId = VoiceCatalog.defaultVoiceId(voices) } + /** + * Built on the main thread (ExoPlayer requirement). Recreatable (H1): + * `finish()` (the "Thoát" confirm) does not guarantee the process dies, + * so a plain `by lazy` singleton would hand every ViewModel an + * already-released, permanently-dead player on a relaunch into the same + * cached process. [releaseVoicePlayer] drops the reference on an + * explicit exit; the next access here rebuilds a working one. + */ + private val voicePlayerHolder: VoicePlayerHolder by lazy { + VoicePlayerHolder { + ExoVoicePlayer(this).also { it.voiceId = VoiceCatalog.defaultVoiceId(voices) } + } } + val voicePlayer: VoicePlayerApi get() = voicePlayerHolder.value + + /** Release the app-scoped player on an explicit, final exit (MainActivity.onDestroy(), isFinishing). */ + fun releaseVoicePlayer() = voicePlayerHolder.release() override fun onCreate() { super.onCreate() + // Two unrelated jobs on two launches (M4): they used to share one + // coroutine with the round-state restore first, so a slow/failed + // DataStore read for the round would stall voice-id sync too. Each + // still races the other independently — PlayerBoardViewModel's + // settings/masterStore.state combine() is what makes that race safe + // rather than this ordering. + appScope.launch { masterStore.restore() } appScope.launch { - masterStore.restore() // Keep the announcer on the configured voice. settingsState.collect { voicePlayer.voiceId = it.voice } } diff --git a/android/app/src/main/java/com/miti99/loto/MainActivity.kt b/android/app/src/main/java/com/miti99/loto/MainActivity.kt index 6c6f548..7d9e68e 100644 --- a/android/app/src/main/java/com/miti99/loto/MainActivity.kt +++ b/android/app/src/main/java/com/miti99/loto/MainActivity.kt @@ -47,6 +47,21 @@ class MainActivity : ComponentActivity() { masterViewModel.setForeground(false) } + override fun onDestroy() { + super.onDestroy() + // L1: release the app-scoped ExoPlayer only on an explicit, final + // exit (the "Thoát" confirm below calls finish()), not on a + // recreate (rotation/config change also calls onDestroy without + // isFinishing). finish() does not guarantee the process dies — + // Android can relaunch into the same cached process/Application + // instance — so LotoApplication.releaseVoicePlayer() (H1) drops the + // reference rather than leaving a terminal `by lazy` player behind; + // the next voicePlayer access rebuilds a working one. + if (isFinishing) { + (application as LotoApplication).releaseVoicePlayer() + } + } + override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) diff --git a/android/app/src/main/java/com/miti99/loto/audio/VoicePlayerHolder.kt b/android/app/src/main/java/com/miti99/loto/audio/VoicePlayerHolder.kt new file mode 100644 index 0000000..57f2da4 --- /dev/null +++ b/android/app/src/main/java/com/miti99/loto/audio/VoicePlayerHolder.kt @@ -0,0 +1,30 @@ +package com.miti99.loto.audio + +/** + * Recreatable holder for an app-scoped [VoicePlayerApi]. A plain `by lazy` + * singleton is one-shot: once [release] is called the delegate is terminal + * (per [VoicePlayerApi.release]'s own contract), but `finish()` does not + * guarantee the process dies — Android can relaunch the same cached process, + * reusing the same `Application` instance, and a `by lazy` field would then + * hand every ViewModel an already-released, permanently-dead player (H1). + * + * [value] rebuilds via [factory] whenever the current instance has been + * released, so a release-then-relaunch-in-process gets a working player + * again instead of a dead one, while an explicit exit still actually frees + * the previous instance's resources (the original intent behind releasing + * at all, rather than merely cancelling). + */ +class VoicePlayerHolder(private val factory: () -> VoicePlayerApi) { + + private var instance: VoicePlayerApi? = null + + /** Builds a fresh player on first access and after every [release]. */ + val value: VoicePlayerApi + get() = instance ?: factory().also { instance = it } + + /** Release the current player, if any, and drop the reference. */ + fun release() { + instance?.release() + instance = null + } +} diff --git a/android/app/src/main/java/com/miti99/loto/settings/SettingsRepository.kt b/android/app/src/main/java/com/miti99/loto/settings/SettingsRepository.kt index 2af64be..f90cfd2 100644 --- a/android/app/src/main/java/com/miti99/loto/settings/SettingsRepository.kt +++ b/android/app/src/main/java/com/miti99/loto/settings/SettingsRepository.kt @@ -1,5 +1,6 @@ package com.miti99.loto.settings +import android.util.Log import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences @@ -13,6 +14,7 @@ import java.io.IOException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart /** * DataStore-backed settings store. Every field is validated independently on @@ -44,12 +46,36 @@ class SettingsRepository( /** Defaults with the manifest-derived voice filled in. */ val defaults: Settings = Settings(voice = defaultVoiceId) - // IO failures fall back to defaults instead of failing the app-scoped - // stateIn collector (the web swallows every localStorage read error). + // Every read failure — IOException (full disk, revoked storage + // permission) or anything else — falls back to defaults instead of + // failing the app-scoped stateIn collector (the web swallows every + // localStorage read error). M4: an earlier version only swallowed + // IOException and re-threw everything else, which cancelled every + // collector of this flow forever on a non-IO failure — "settings never + // save" (acceptable, L4) had regressed into "settings never load and + // auto-cross is dead for the rest of the process" (not acceptable). A + // broken settings store must degrade to defaults, not to a dead board. val settingsFlow: Flow = dataStore.data - .catch { if (it is IOException) emit(emptyPreferences()) else throw it } + .catch { + Log.w(TAG, "Failed to read settings; falling back to defaults", it) + emit(emptyPreferences()) + } .map { prefs -> toSettings(prefs) } + /** + * True once [settingsFlow] has produced its first value — either the + * real persisted read or the [catch] fallback after a read failure. + * False only for the brief startup window before that read lands (M4 + * residual): [LotoApplication]'s `settingsOrNull` is + * `stateIn(..., SharingStarted.Eagerly, null)`, so its *initial* value + * cannot be confused with a real `mode = PLAYER` — a consumer that + * needs to tell "not loaded yet" apart from "loaded and actually + * PLAYER" (e.g. gating a master-history replay on the real mode) reads + * that instead. This property is kept for callers that only need a + * plain boolean signal (e.g. tests) rather than the settings snapshot. + */ + val loaded: Flow = settingsFlow.map { true }.onStart { emit(false) } + private fun toSettings(prefs: Preferences): Settings = Settings( emptyCellColor = prefs[Keys.EMPTY_CELL_COLOR] ?.takeIf { Settings.HEX6.matches(it) } @@ -108,7 +134,12 @@ class SettingsRepository( private suspend fun write(block: (MutablePreferences) -> Unit) { try { dataStore.edit(block) - } catch (_: IOException) { + } catch (e: IOException) { + Log.w(TAG, "Failed to persist settings; change was not saved", e) } } + + private companion object { + const val TAG = "SettingsRepository" + } } diff --git a/android/app/src/main/java/com/miti99/loto/state/GameStateRepository.kt b/android/app/src/main/java/com/miti99/loto/state/GameStateRepository.kt index 708460c..8f36b1a 100644 --- a/android/app/src/main/java/com/miti99/loto/state/GameStateRepository.kt +++ b/android/app/src/main/java/com/miti99/loto/state/GameStateRepository.kt @@ -1,5 +1,6 @@ package com.miti99.loto.state +import android.util.Log import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences @@ -66,6 +67,12 @@ class GameStateRepository(private val dataStore: DataStore) { val prefs = readPrefs() val called = parseNumberList(prefs[Keys.MASTER_CALLED]) ?: return null val remaining = parseNumberList(prefs[Keys.MASTER_REMAINING]) ?: return null + // L3: called and remaining must exactly partition the 90-number + // deck — disjoint and covering everything. Without this, a + // partially-written/tampered file could restore an overlapping or + // short deck, which PlayerAutoCross would then cross twice (or + // never) on different cells. + if (!isCompleteDeckPartition(called, remaining)) return null return MasterRoundState(called, remaining) } @@ -78,14 +85,18 @@ class GameStateRepository(private val dataStore: DataStore) { private suspend fun readPrefs(): Preferences = try { dataStore.data.first() - } catch (_: IOException) { + } catch (e: IOException) { + // L4: was silently swallowed — a persistently failing store used to + // degrade to "round never restores" with zero signal for device QA. + Log.w(TAG, "Failed to read game state; falling back to empty state", e) emptyPreferences() } private suspend fun write(block: (MutablePreferences) -> Unit) { try { dataStore.edit(block) - } catch (_: IOException) { + } catch (e: IOException) { + Log.w(TAG, "Failed to persist game state; change was not saved", e) } } @@ -94,6 +105,10 @@ class GameStateRepository(private val dataStore: DataStore) { if (raw.isNullOrEmpty()) return null val nums = raw.split(",").map { it.toIntOrNull() ?: return null } if (nums.size != cells || nums.any { it !in 0..90 }) return null + // L3: a real card never repeats a number; a tampered/corrupt file + // could otherwise restore duplicate cells. + val nonZero = nums.filter { it != 0 } + if (nonZero.toSet().size != nonZero.size) return null return nums.chunked(CardGenerator.NUM_COLS) } @@ -103,12 +118,37 @@ class GameStateRepository(private val dataStore: DataStore) { return raw.map { it == '1' }.chunked(CardGenerator.NUM_COLS) } - /** Comma-separated ints, each in 1..90. Empty string = empty list. */ + /** Comma-separated ints, each in 1..90, no duplicates. Empty string = empty list. */ private fun parseNumberList(raw: String?): List? { if (raw == null) return null if (raw.isEmpty()) return emptyList() val nums = raw.split(",").map { it.toIntOrNull() ?: return null } if (nums.any { it !in 1..90 }) return null + // L3: reject duplicates rather than silently accepting them — a + // repeated `called`/`remaining` entry would double-cross a cell. + if (nums.toSet().size != nums.size) return null return nums } + + /** + * L3: [called] and [remaining] must be disjoint and together cover + * exactly 1..90 (every number in range appears exactly once across the + * two lists). [parseNumberList] already rejects duplicates and + * out-of-range values within each list individually, so checking the + * combined size against 90 is sufficient to also catch an overlap + * between the two lists. + */ + private fun isCompleteDeckPartition(called: List, remaining: List): Boolean { + val total = called.size + remaining.size + if (total != DECK_SIZE) return false + val combined = HashSet(total) + combined.addAll(called) + combined.addAll(remaining) + return combined.size == total + } + + private companion object { + const val DECK_SIZE = 90 + const val TAG = "GameStateRepository" + } } diff --git a/android/app/src/main/java/com/miti99/loto/state/LotoViewModelFactory.kt b/android/app/src/main/java/com/miti99/loto/state/LotoViewModelFactory.kt index 7f3318a..6d20580 100644 --- a/android/app/src/main/java/com/miti99/loto/state/LotoViewModelFactory.kt +++ b/android/app/src/main/java/com/miti99/loto/state/LotoViewModelFactory.kt @@ -16,8 +16,9 @@ class LotoViewModelFactory(private val app: LotoApplication) : ViewModelProvider PlayerBoardViewModel( repository = app.gameStateRepository, masterStore = app.masterStore, - settings = app.settingsState, + settingsOrNull = app.settingsOrNull, voicePlayer = app.voicePlayer, + fallbackSettings = app.settingsRepository.defaults, ) as T modelClass.isAssignableFrom(MasterPanelViewModel::class.java) -> diff --git a/android/app/src/main/java/com/miti99/loto/state/MasterPanelViewModel.kt b/android/app/src/main/java/com/miti99/loto/state/MasterPanelViewModel.kt index b9ac535..d3d84d5 100644 --- a/android/app/src/main/java/com/miti99/loto/state/MasterPanelViewModel.kt +++ b/android/app/src/main/java/com/miti99/loto/state/MasterPanelViewModel.kt @@ -28,6 +28,9 @@ class MasterPanelViewModel( /** Master round data (called history + remaining deck). */ val masterState: StateFlow = masterStore.state + /** True until the startup restore has resolved; gates "Ván mới" (H1). */ + val loading: StateFlow = masterStore.loading + private val _autoRunning = MutableStateFlow(false) val autoRunning: StateFlow = _autoRunning @@ -85,6 +88,18 @@ class MasterPanelViewModel( fun drawNext() { val next = masterStore.drawNext() ?: return _tickKey.value += 1 + // L5: flip autoRunning off the instant the draw that empties the + // deck lands, not on the *next* tick. Previously only the ticker + // loop's own top-of-loop check caught this, one tick late — and + // once the button hiding on remaining.isNotEmpty() kicks in, + // toggleAuto() can no longer reach it either (it early-returns when + // remaining is empty), so the stale `true` was unrecoverable until + // that late tick. Also correct for a manual "Xổ số" draw (not from + // the auto ticker) that happens to be the one that exhausts the + // deck while auto-call was separately left on. + if (masterStore.state.value.remaining.isEmpty()) { + _autoRunning.value = false + } if (settings.value.voiceEnabledMaster) { voicePlayer.playNumber(next) } @@ -103,7 +118,17 @@ class MasterPanelViewModel( } override fun onCleared() { - voicePlayer.cancel() + // L2: voicePlayer is an app-scoped singleton also cancelled by + // PlayerBoardViewModel.onCleared(). Both VMs are activity-scoped + // (LotoViewModelFactory), so onCleared() only fires together, on a + // real finish — MainActivity.onDestroy() already stops/releases the + // player unconditionally on that same isFinishing path (L1). A + // per-VM cancel() here was therefore redundant *and* fragile: if + // either VM's scope ever changed to clear independently of the + // other, one screen tearing down could cut audio the other screen + // is still using. Not cancelling here relies solely on + // MainActivity's guarded release() to stop playback on exit. + super.onCleared() } } diff --git a/android/app/src/main/java/com/miti99/loto/state/MasterStore.kt b/android/app/src/main/java/com/miti99/loto/state/MasterStore.kt index 7285750..4260ecb 100644 --- a/android/app/src/main/java/com/miti99/loto/state/MasterStore.kt +++ b/android/app/src/main/java/com/miti99/loto/state/MasterStore.kt @@ -21,15 +21,29 @@ class MasterStore( private val _state = MutableStateFlow(MasterRoundState(emptyList(), emptyList())) val state: StateFlow = _state + private val _loading = MutableStateFlow(true) + /** True until [restore] has resolved once at startup; drives button gating. */ + val loading: StateFlow = _loading + + // Set by startNewGame()/drawNext() so a still-in-flight restore() never + // clobbers a round the user already started while the DataStore read was + // pending (H1: restore races "Ván mới" the same way it races the + // player's "Tạo bảng mới"). + private var mutatedBeforeRestore = false + /** Restore the persisted round, if any. Call once on startup. */ suspend fun restore() { - val saved = repository.loadMasterState() ?: return - deck.restore(saved.called, saved.remaining) - _state.value = saved + val saved = repository.loadMasterState() + if (saved != null && !mutatedBeforeRestore) { + deck.restore(saved.called, saved.remaining) + _state.value = saved + } + _loading.value = false } /** Start a fresh round: empty called, full shuffled remaining. */ fun startNewGame() { + mutatedBeforeRestore = true deck.startNewGame() publishAndSave() } @@ -40,6 +54,7 @@ class MasterStore( */ fun drawNext(): Int? { val next = deck.drawNext() ?: return null + mutatedBeforeRestore = true publishAndSave() return next } diff --git a/android/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt b/android/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt index ec2e3ec..b6b41ec 100644 --- a/android/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt +++ b/android/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt @@ -10,11 +10,15 @@ import com.miti99.loto.game.PlayerAutoCross import com.miti99.loto.game.PlayerCard import com.miti99.loto.settings.AppMode import com.miti99.loto.settings.Settings +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Immutable UI state of the player board. Derived fields (row completeness, @@ -29,13 +33,27 @@ data class PlayerUiState( val waitingCells: Set = emptySet(), /** Per 3-row section (Tân Tân card bands): any row inside is waiting. */ val sectionWaiting: List = listOf(false, false, false), - /** Transient "Chờ N" chip text, auto-dismissed after 5s. */ - val toast: String? = null, + /** Transient "Chờ N" chip number, auto-dismissed after 5s; null = hidden. */ + val waitingNumber: Int? = null, val showCongrats: Boolean = false, /** 1-based row number shown in the Kinh modal. */ val congratsRow: Int = 0, /** 1 = normal, 2 = confetti celebration. */ val celebrationTier: Int = 1, + /** + * True until [PlayerBoardViewModel]'s startup restore AND the settings + * DataStore's first read have both resolved (M3). `grid == null` alone + * cannot distinguish "no card yet" from "not loaded yet" — the UI must + * gate the no-confirm generate branch on this instead, or a tap during + * the DataStore read races the restore (see restore()). Folding the + * settings gate in here too closes a second race: without it, a tap in + * this window with a persisted `mode = BOTH` and a restored master + * history would read the settings placeholder default and permanently + * lose the replay (`generate()`/`clearMarks()` compute their auto-cross + * cursor eagerly, and the master's called[] does not re-emit on its + * own once that cursor is wrong). + */ + val loading: Boolean = true, ) /** @@ -47,10 +65,44 @@ data class PlayerUiState( class PlayerBoardViewModel( private val repository: GameStateRepository, private val masterStore: MasterStore, - private val settings: StateFlow, + /** + * Null = the settings DataStore's first real read has not resolved yet + * (M2/M4 residual). A single flow, rather than a separate `settings` + * snapshot plus a separate `loaded` boolean, is deliberate: two + * independent collectors of the same upstream settings flow (as + * `LotoApplication` used to expose) cannot be trusted to update in step + * with each other, so reading "is it loaded" and "what is the mode" from + * two different flows could observe two different snapshots — the gate + * could open while the mode read still returned the placeholder default + * (M2). Reading both off this one flow's `.value` makes that + * impossible: they are, by construction, always the same snapshot. + * Production wiring ([com.miti99.loto.state.LotoViewModelFactory]) + * passes `LotoApplication.settingsOrNull`. + */ + private val settingsOrNull: StateFlow, private val voicePlayer: VoicePlayerApi, + /** + * Where [CardGenerator.generateGrid] runs (M5's up-to-200-attempt + * rejection loop). Overridable so unit tests can substitute the + * `StandardTestDispatcher` also installed as Main, keeping the whole + * flow on one deterministic virtual clock instead of racing a real + * background thread against `runCurrent()`/`advanceUntilIdle()`. + */ + private val computeDispatcher: CoroutineDispatcher = Dispatchers.Default, + /** + * Used only for the narrow pre-load window where [settingsOrNull] is + * still null but a restored, already-clickable grid can be interacted + * with (`onCellClick` → `detectRowEvents`). Production wiring supplies + * the manifest-derived defaults; tests that never exercise that window + * (i.e. every test whose `settingsOrNull` is a non-null-typed + * `StateFlow`) never read this. + */ + private val fallbackSettings: Settings = Settings(voice = ""), ) : ViewModel() { + /** Current settings snapshot, falling back only in the pre-load window (see [fallbackSettings]). */ + private val settings: Settings get() = settingsOrNull.value ?: fallbackSettings + private val _uiState = MutableStateFlow(PlayerUiState()) val uiState: StateFlow = _uiState @@ -73,31 +125,80 @@ class PlayerBoardViewModel( private var toastJob: Job? = null + // True once restore()'s DataStore read has resolved. Combined with + // `settingsOrNull != null` to compute the published `loading` flag (M3) + // — see publishLoading(). + private var restoreDone = false + + // Guards generate() against a second rapid tap enqueueing a second + // generation while the first is still computing off-main (L-a): `grid` + // is only assigned at the very end of applyGeneratedGrid, so a + // grid == null check alone does not catch an in-flight generation. + private var generating = false + init { viewModelScope.launch { restore() - // Collect after restore so the replay sees the loaded grid. - masterStore.state.collect { master -> onMasterState(master) } + // combine (not collect) so a settings arrival that lands after + // the first master emission re-runs the replay against the + // current snapshot, and so a settings change also republishes + // `loading` (M3). masterStore.restore() and the settings + // DataStore read are two independent async reads that race + // independently (M4): without this, a master emission evaluated + // while settings still held its placeholder default would + // permanently miss a persisted `mode = BOTH`, since + // masterStore.state does not emit again until the next draw. + // + // Gating on `settingsOrNull != null` closes the reverse + // ordering combine() alone does not: if masterStore's restore + // lands with the full called history *before* settings ever + // resolves, evaluating that emission under a placeholder mode + // would advance lastHandledIndex to called.size with + // changed = false — a silent no-op that permanently loses the + // replay, since masterStore.state won't re-emit until the next + // draw. Skipping onMasterState entirely while not loaded leaves + // the cursor untouched, so once settings resolves, combine's + // cached master value is re-delivered and replayed under the + // real mode. generate()/clearMarks() have their own guard for + // the same reason (M3): this combine alone does not protect a + // tap that runs before the first re-evaluation. + combine(masterStore.state, settingsOrNull) { master, snapshot -> master to snapshot } + .collect { (master, snapshot) -> + publishLoading() + if (snapshot != null) onMasterState(master) + } } } + private fun publishLoading() { + publish(loading = !restoreDone || settingsOrNull.value == null) + } + private suspend fun restore() { - val saved = repository.loadPlayerState() ?: return - grid = saved.grid - crossed = saved.crossed - manualUnticks.clear() - manualUnticks.addAll(saved.manualUnticks) - // Seed the row trackers so restoring a finished/waiting row does not - // re-announce it. - celebratedRows.clear() - notifiedWaitingRows.clear() - for (i in saved.grid.indices) { - if (PlayerCard.isRowComplete(saved.grid, saved.crossed, i)) celebratedRows.add(i) - if (PlayerCard.getWaitingNumber(saved.grid, saved.crossed, i) != null) { - notifiedWaitingRows.add(i) + val saved = repository.loadPlayerState() + // H1: generate()/clearMarks() can run while this suspends on the + // DataStore read (the UI gates on `loading`, but guard here too in + // case a caller acts before that gate resolves). Never let a stale + // persisted round overwrite a card the user already produced, and + // never overwrite one that is still being computed (L-a). + if (saved != null && grid == null && !generating) { + grid = saved.grid + crossed = saved.crossed + manualUnticks.clear() + manualUnticks.addAll(saved.manualUnticks) + // Seed the row trackers so restoring a finished/waiting row does + // not re-announce it. + celebratedRows.clear() + notifiedWaitingRows.clear() + for (i in saved.grid.indices) { + if (PlayerCard.isRowComplete(saved.grid, saved.crossed, i)) celebratedRows.add(i) + if (PlayerCard.getWaitingNumber(saved.grid, saved.crossed, i) != null) { + notifiedWaitingRows.add(i) + } } } - publish() + restoreDone = true + publishLoading() } private fun onMasterState(master: MasterRoundState) { @@ -108,14 +209,27 @@ class PlayerBoardViewModel( // any shrink (not only ==0) so a reset conflated with the new // round's first draws is still detected; the fall-through replay // then applies those draws to the cleared board. - if (prev > 0 && len < prev && settings.value.mode == AppMode.BOTH && grid != null) { - crossed = grid!!.map { row -> row.map { false } } + val shrunk = prev > 0 && len < prev + if (shrunk) { + // L6: manual-untick suppressions from the previous round must + // not survive a "Ván mới" in ANY mode, not only BOTH — a manual + // untick can be recorded whenever a number is in the master's + // called[] regardless of the currently displayed mode + // (onCellClick has no mode gate), so a later switch to BOTH + + // "Xoá đánh dấu" would otherwise replay with suppressions left + // over from a round that no longer exists. + val hadUnticks = manualUnticks.isNotEmpty() manualUnticks.clear() - lastHandledIndex = 0 - celebratedRows.clear() - notifiedWaitingRows.clear() - persist() - publish() + if (settings.mode == AppMode.BOTH && grid != null) { + crossed = grid!!.map { row -> row.map { false } } + lastHandledIndex = 0 + celebratedRows.clear() + notifiedWaitingRows.clear() + persist() + publish() + } else if (hadUnticks) { + persist() + } } val result = PlayerAutoCross.applyMasterCalls( grid = grid, @@ -123,7 +237,7 @@ class PlayerBoardViewModel( called = master.called, lastHandledIndex = lastHandledIndex, manualUnticks = manualUnticks, - mode = settings.value.mode, + mode = settings.mode, ) lastHandledIndex = result.lastHandledIndex if (result.changed) { @@ -134,16 +248,43 @@ class PlayerBoardViewModel( } } - /** Generate a fresh card (the UI owns the confirmation dialog). */ + /** + * Generate a fresh card (the UI owns the confirmation dialog). + * `CardGenerator.generateGrid()` can retry up to 200 times (M5), so the + * actual generation runs off the main thread; only the pure grid + * computation is offloaded — the state mutations below still happen on + * the main dispatcher. + * + * No-ops while settings have not resolved yet (M3): the mode-dependent + * replay below would otherwise compute the auto-cross cursor against a + * placeholder mode, and — since it is not re-evaluated once settings do + * resolve — permanently lose the replay. The UI already disables the + * button on `state.loading` for the same reason; this is the + * in-case-a-caller-acts-before-that-gate-resolves guard, mirroring + * restore()'s own defense. Also no-ops while a previous call is still + * computing (L-a), since `grid` alone cannot detect an in-flight + * generation. + */ fun generate() { + if (settingsOrNull.value == null || generating) return + generating = true voicePlayer.cancel() - val newGrid = CardGenerator.generateGrid() + viewModelScope.launch(computeDispatcher) { + val newGrid = CardGenerator.generateGrid() + withContext(Dispatchers.Main.immediate) { + applyGeneratedGrid(newGrid) + generating = false + } + } + } + + private fun applyGeneratedGrid(newGrid: List>) { var newCrossed = newGrid.map { row -> row.map { false } } manualUnticks.clear() // Replay the master's called[] onto the fresh grid so the host // doesn't restart from zero when regenerating mid-game (locked // decision); outside both mode just advance the cursor. - if (settings.value.mode == AppMode.BOTH) { + if (settings.mode == AppMode.BOTH) { val result = PlayerAutoCross.applyMasterCalls( grid = newGrid, crossed = newCrossed, @@ -169,15 +310,19 @@ class PlayerBoardViewModel( detectRowEvents() } - /** Clear all marks (the UI owns the confirmation dialog). */ + /** + * Clear all marks (the UI owns the confirmation dialog). No-ops while + * settings have not resolved yet — same M3 rationale as [generate]. + */ fun clearMarks() { + if (settingsOrNull.value == null) return val g = grid ?: return voicePlayer.cancel() var cleared = g.map { row -> row.map { false } } manualUnticks.clear() // In both mode, immediately replay the master's called[] (locked // decision: clear → re-cross all currently-called numbers). - if (settings.value.mode == AppMode.BOTH) { + if (settings.mode == AppMode.BOTH) { val result = PlayerAutoCross.applyMasterCalls( grid = g, crossed = cleared, @@ -224,8 +369,8 @@ class PlayerBoardViewModel( fun dismissToast() { toastJob?.cancel() toastJob = null - if (_uiState.value.toast != null) { - _uiState.value = _uiState.value.copy(toast = null) + if (_uiState.value.waitingNumber != null) { + _uiState.value = _uiState.value.copy(waitingNumber = null) } } @@ -236,7 +381,12 @@ class PlayerBoardViewModel( } override fun onCleared() { - voicePlayer.cancel() + // L2: see MasterPanelViewModel.onCleared() — voicePlayer is a + // shared singleton; MainActivity.onDestroy() already stops it on + // the same isFinishing condition this onCleared() fires under, so + // cancelling it here too was redundant and coupled two + // independently-clearable owners to one shared resource. + super.onCleared() } /** @@ -247,7 +397,7 @@ class PlayerBoardViewModel( private fun detectRowEvents() { val g = grid ?: return if (crossed.isEmpty()) return - val s = settings.value + val s = settings // The master takes over announcer duties in both mode, so its voice // flag also drives Chờ/Kinh. Solo players keep their own flag. val announce = s.voiceEnabledPlayer || (s.voiceEnabledMaster && s.mode == AppMode.BOTH) @@ -279,7 +429,7 @@ class PlayerBoardViewModel( val waitNum = PlayerCard.getWaitingNumber(g, crossed, i) if (waitNum != null && i !in notifiedWaitingRows) { notifiedWaitingRows.add(i) - showToast("Chờ $waitNum") + showWaitingToast(waitNum) if (announce) { voicePlayer.playWaiting( waitNum, @@ -293,12 +443,12 @@ class PlayerBoardViewModel( } } - private fun showToast(message: String) { + private fun showWaitingToast(waitNum: Int) { toastJob?.cancel() - _uiState.value = _uiState.value.copy(toast = message) + _uiState.value = _uiState.value.copy(waitingNumber = waitNum) toastJob = viewModelScope.launch { delay(TOAST_DURATION_MS) - _uiState.value = _uiState.value.copy(toast = null) + _uiState.value = _uiState.value.copy(waitingNumber = null) } } @@ -308,7 +458,7 @@ class PlayerBoardViewModel( viewModelScope.launch { repository.savePlayerState(snapshot) } } - private fun publish(showCongrats: Boolean? = null) { + private fun publish(showCongrats: Boolean? = null, loading: Boolean? = null) { val g = grid if (g == null || crossed.isEmpty()) { _uiState.value = _uiState.value.copy( @@ -318,6 +468,7 @@ class PlayerBoardViewModel( waitingCells = emptySet(), sectionWaiting = listOf(false, false, false), showCongrats = showCongrats ?: _uiState.value.showCongrats, + loading = loading ?: _uiState.value.loading, ) return } @@ -345,6 +496,7 @@ class PlayerBoardViewModel( waitingCells = waitingCells, sectionWaiting = sectionWaiting, showCongrats = showCongrats ?: _uiState.value.showCongrats, + loading = loading ?: _uiState.value.loading, ) } diff --git a/android/app/src/main/java/com/miti99/loto/ui/ColorParsing.kt b/android/app/src/main/java/com/miti99/loto/ui/ColorParsing.kt index dbc6851..27b04a9 100644 --- a/android/app/src/main/java/com/miti99/loto/ui/ColorParsing.kt +++ b/android/app/src/main/java/com/miti99/loto/ui/ColorParsing.kt @@ -1,13 +1,18 @@ package com.miti99.loto.ui import androidx.compose.ui.graphics.Color +import androidx.core.graphics.toColorInt /** * Parse a hex6 setting value ("#RRGGBB") into a Compose color. The settings * layer already validates the format; the fallback guards direct callers. + * + * L7: `android.graphics.Color.parseColor` is deprecated in favor of + * `androidx.core`'s `String.toColorInt()`, which keeps the same + * `IllegalArgumentException` contract for an unparsable string. */ fun String.toComposeColor(): Color = try { - Color(android.graphics.Color.parseColor(this)) + Color(this.toColorInt()) } catch (_: IllegalArgumentException) { Color(0xFF7030A0) } diff --git a/android/app/src/main/java/com/miti99/loto/ui/master/MasterPanelScreen.kt b/android/app/src/main/java/com/miti99/loto/ui/master/MasterPanelScreen.kt index 78dc629..fa365c0 100644 --- a/android/app/src/main/java/com/miti99/loto/ui/master/MasterPanelScreen.kt +++ b/android/app/src/main/java/com/miti99/loto/ui/master/MasterPanelScreen.kt @@ -60,6 +60,7 @@ fun MasterPanelScreen( ) { val palette = LotoTheme.palette val master by viewModel.masterState.collectAsState() + val loading by viewModel.loading.collectAsState() val autoRunning by viewModel.autoRunning.collectAsState() val tickKey by viewModel.tickKey.collectAsState() val reducedMotion = rememberReducedMotion() @@ -69,7 +70,10 @@ fun MasterPanelScreen( val lastCalled = master.called.lastOrNull() val heroRequester = remember { BringIntoViewRequester() } - // Keep the hero visible on each draw (the web scrolls it into view). + // Keep the hero visible on each draw, including in `both` mode: web + // parity confirmed against MasterPanel.svelte's `handleDrawNext`, which + // sets `scrollOnNextDraw = true` unconditionally for both the manual + // draw button and the auto-call interval, regardless of `settings.mode`. LaunchedEffect(tickKey) { if (tickKey > 0 && lastCalled != null) heroRequester.bringIntoView() } @@ -83,6 +87,11 @@ fun MasterPanelScreen( PillButton( text = stringResource(R.string.master_new_game), container = palette.masterNewGame, + // Gated on loading (H1): while the startup restore is still + // resolving, `hasGame` reads false regardless of what is + // actually persisted, so an early tap would call newGame() + // with no confirmation and race the restore. + enabled = !loading, onClick = { if (hasGame) confirmNewGame = true else viewModel.newGame() }, ) if (hasGame && master.remaining.isNotEmpty()) { @@ -234,9 +243,15 @@ fun MasterPanelScreen( } @Composable -private fun PillButton(text: String, container: Color, onClick: () -> Unit) { +private fun PillButton( + text: String, + container: Color, + onClick: () -> Unit, + enabled: Boolean = true, +) { Button( onClick = onClick, + enabled = enabled, shape = RoundedCornerShape(50), colors = ButtonDefaults.buttonColors(containerColor = container, contentColor = Color.White), ) { diff --git a/android/app/src/main/java/com/miti99/loto/ui/player/KinhDialog.kt b/android/app/src/main/java/com/miti99/loto/ui/player/KinhDialog.kt index d09c2f4..2c13c28 100644 --- a/android/app/src/main/java/com/miti99/loto/ui/player/KinhDialog.kt +++ b/android/app/src/main/java/com/miti99/loto/ui/player/KinhDialog.kt @@ -1,9 +1,12 @@ package com.miti99.loto.ui.player import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -19,76 +22,98 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import com.miti99.loto.R import com.miti99.loto.ui.theme.LotoTheme /** * The "Kinh!" (row win) celebration modal, ported from PlayerBoard.svelte. * Back press and backdrop tap dismiss it (Dialog's onDismissRequest). + * + * Tier-2 confetti is composed *inside* this Dialog's own window + * (`usePlatformDefaultWidth = false` so the window spans the full screen) + * rather than as a sibling in the caller's scroll column: a sibling both + * degrades to wrap size under an unbounded-height `verticalScroll` and + * renders behind the dialog's dim scrim, making it invisible (H2). */ @Composable fun KinhDialog( congratsRow: Int, + showConfetti: Boolean, onDismiss: () -> Unit, ) { val palette = LotoTheme.palette - Dialog(onDismissRequest = onDismiss) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(24.dp)) - .background(palette.dialogBg) - .padding(32.dp), + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = Modifier.fillMaxSize().padding(24.dp), + contentAlignment = Alignment.Center, ) { - Text(text = "🎉", fontSize = 48.sp) - Text( - text = stringResource(R.string.kinh_title), - color = palette.kinhTitle, - fontSize = 36.sp, - fontWeight = FontWeight.Black, - modifier = Modifier.padding(top = 8.dp), - ) - Text( - text = stringResource(R.string.kinh_row_prefix), - color = palette.dialogText, - fontSize = 16.sp, - modifier = Modifier.padding(top = 12.dp), - ) - Text( - text = congratsRow.toString(), - color = palette.kinhRow, - fontSize = 56.sp, - fontWeight = FontWeight.Black, - modifier = Modifier.padding(vertical = 4.dp), - ) - Text( - text = stringResource(R.string.kinh_row_suffix), - color = palette.dialogText, - fontSize = 16.sp, - ) - Text( - text = stringResource(R.string.kinh_shout), - color = palette.subtitle, - fontSize = 14.sp, - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = 6.dp), - ) - Button( - onClick = onDismiss, - shape = RoundedCornerShape(50), - colors = ButtonDefaults.buttonColors( - containerColor = palette.buttonPrimary, - contentColor = Color.White, - ), - modifier = Modifier.padding(top = 24.dp), + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .widthIn(max = 360.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(palette.dialogBg) + .padding(32.dp), ) { + Text(text = "🎉", fontSize = 48.sp) Text( - text = stringResource(R.string.kinh_dismiss), - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + text = stringResource(R.string.kinh_title), + color = palette.kinhTitle, + fontSize = 36.sp, + fontWeight = FontWeight.Black, + modifier = Modifier.padding(top = 8.dp), ) + Text( + text = stringResource(R.string.kinh_row_prefix), + color = palette.dialogText, + fontSize = 16.sp, + modifier = Modifier.padding(top = 12.dp), + ) + Text( + text = congratsRow.toString(), + color = palette.kinhRow, + fontSize = 56.sp, + fontWeight = FontWeight.Black, + modifier = Modifier.padding(vertical = 4.dp), + ) + Text( + text = stringResource(R.string.kinh_row_suffix), + color = palette.dialogText, + fontSize = 16.sp, + ) + Text( + text = stringResource(R.string.kinh_shout), + color = palette.subtitle, + fontSize = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 6.dp), + ) + Button( + onClick = onDismiss, + shape = RoundedCornerShape(50), + colors = ButtonDefaults.buttonColors( + containerColor = palette.buttonPrimary, + contentColor = Color.White, + ), + modifier = Modifier.padding(top = 24.dp), + ) { + Text( + text = stringResource(R.string.kinh_dismiss), + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + } + // Drawn after (on top of) the card, still within this window and + // above its scrim. + if (showConfetti) { + ConfettiOverlay() } } } diff --git a/android/app/src/main/java/com/miti99/loto/ui/player/PlayerBoardScreen.kt b/android/app/src/main/java/com/miti99/loto/ui/player/PlayerBoardScreen.kt index 46d02eb..55f1d67 100644 --- a/android/app/src/main/java/com/miti99/loto/ui/player/PlayerBoardScreen.kt +++ b/android/app/src/main/java/com/miti99/loto/ui/player/PlayerBoardScreen.kt @@ -71,6 +71,11 @@ fun PlayerBoardScreen( onClick = { if (state.grid != null) confirmGenerate = true else viewModel.generate() }, + // Gated on loading (H1): while the startup restore is still + // resolving, `grid` reads null regardless of what is + // actually persisted, so an early tap would call generate() + // with no confirmation and race the restore. + enabled = !state.loading, shape = RoundedCornerShape(50), colors = ButtonDefaults.buttonColors( containerColor = palette.buttonPrimary, @@ -127,13 +132,13 @@ fun PlayerBoardScreen( }, ) // Chờ toast — centered overlay chip, tap to dismiss. - state.toast?.let { message -> + state.waitingNumber?.let { waitNum -> Box( contentAlignment = Alignment.Center, modifier = Modifier.matchParentSize(), ) { Text( - text = message, + text = stringResource(R.string.toast_waiting, waitNum), color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.Black, @@ -152,10 +157,16 @@ fun PlayerBoardScreen( } if (state.showCongrats) { - KinhDialog(congratsRow = state.congratsRow, onDismiss = { viewModel.dismissCongrats() }) - if (state.celebrationTier >= 2 && !reducedMotion) { - ConfettiOverlay() - } + // H2: confetti must render inside the same window as the Kinh + // dialog. As a sibling in this screen's scrolling column, + // ConfettiOverlay's fillMaxSize degrades to wrap (unbounded max + // height under verticalScroll), and it would draw behind the + // dialog's dim scrim regardless. KinhDialog hosts it internally. + KinhDialog( + congratsRow = state.congratsRow, + showConfetti = state.celebrationTier >= 2 && !reducedMotion, + onDismiss = { viewModel.dismissCongrats() }, + ) } if (confirmGenerate) { diff --git a/android/app/src/main/java/com/miti99/loto/ui/player/PlayerCell.kt b/android/app/src/main/java/com/miti99/loto/ui/player/PlayerCell.kt index cd5c02b..c993f1f 100644 --- a/android/app/src/main/java/com/miti99/loto/ui/player/PlayerCell.kt +++ b/android/app/src/main/java/com/miti99/loto/ui/player/PlayerCell.kt @@ -20,12 +20,14 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp +import com.miti99.loto.R import com.miti99.loto.ui.theme.CondensedNumberFont import com.miti99.loto.ui.theme.LotoTheme @@ -73,6 +75,12 @@ fun PlayerCell( } val crossColor = if (rowComplete) palette.crossWinStroke else palette.crossStroke + // M3/L7-adjacent cleanup: cell accessibility copy lives in strings.xml + // rather than hardcoded Vietnamese, matching the toast fix. + val cellNumberDesc = stringResource(R.string.player_cell_number, num) + val crossedSuffixDesc = stringResource(R.string.player_cell_crossed_suffix) + val waitingSuffixDesc = stringResource(R.string.player_cell_waiting_suffix) + // Waiting pulse — 1.6s breathing ring, static at 0.7 alpha under // reduced motion (mirrors the web's cell-waiting keyframes). val ringAlpha: Float = if (isWaiting && !reducedMotion) { @@ -122,9 +130,9 @@ fun PlayerCell( .clickable(onClick = onClick) .semantics { stateDescription = buildString { - append("Số $num") - if (isCrossed) append(", đã đánh dấu") - if (isWaiting) append(", đang chờ") + append(cellNumberDesc) + if (isCrossed) append(crossedSuffixDesc) + if (isWaiting) append(waitingSuffixDesc) } }, ) { diff --git a/android/app/src/main/java/com/miti99/loto/ui/settings/EmptyCellColorPicker.kt b/android/app/src/main/java/com/miti99/loto/ui/settings/EmptyCellColorPicker.kt index ee7eca4..905fe25 100644 --- a/android/app/src/main/java/com/miti99/loto/ui/settings/EmptyCellColorPicker.kt +++ b/android/app/src/main/java/com/miti99/loto/ui/settings/EmptyCellColorPicker.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.unit.sp import com.miti99.loto.R import com.miti99.loto.ui.theme.LotoTheme import com.miti99.loto.ui.toComposeColor +import kotlin.math.roundToInt /** Office "Standard Colors" palette (10 swatches), same as the web. Default is Purple. */ private val PRESETS = listOf( @@ -56,9 +57,11 @@ fun EmptyCellColorPicker( var draft by remember { mutableStateOf(null) } val shown = draft ?: value val color = shown.toComposeColor() - val r = (color.red * 255).toInt() - val g = (color.green * 255).toInt() - val b = (color.blue * 255).toInt() + // roundToInt, not toInt: truncation drifted the untouched channels down + // on every drag since `draft` is rebuilt from these derived values (M1). + val r = (color.red * 255).roundToInt() + val g = (color.green * 255).roundToInt() + val b = (color.blue * 255).roundToInt() val commitDraft: () -> Unit = { draft?.let(onPick) draft = null diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 29d0f9c..24c9e11 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -11,9 +11,10 @@ Nhấn để bắt đầu chơi 🎫 Chúc cả nhà một ván vui vẻ - Bảng lô tô Chờ %1$d - Đóng thông báo + Số %1$d + , đã đánh dấu + , đang chờ Lô tô @@ -26,7 +27,6 @@ đã đầy đủ! Hãy hô to "Kinh!" 🎶 Tuyệt vời! 🥳 - Đóng Quản trò @@ -39,7 +39,6 @@ Số vừa xổ Đã xổ: %1$d/90 · Còn lại: %2$d Thứ tự đã xổ: - Bảng theo dõi số đã xổ Đếm ngược: %1$d giây Chế độ Quản trò Nhấn diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..e1e3d8a --- /dev/null +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..4fde2d3 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + diff --git a/android/app/src/test/java/com/miti99/loto/SlowDataStore.kt b/android/app/src/test/java/com/miti99/loto/SlowDataStore.kt new file mode 100644 index 0000000..bea830a --- /dev/null +++ b/android/app/src/test/java/com/miti99/loto/SlowDataStore.kt @@ -0,0 +1,40 @@ +package com.miti99.loto + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow + +/** + * Wraps a real [DataStore] (typically [InMemoryDataStore]) but delays every + * [data] emission until [gate] completes — the unit-test stand-in for the + * real async gap between a [DataStore] being constructed and its first + * emission landing. + * + * [InMemoryDataStore] alone resolves `dataStore.data.first()` synchronously, + * which is why the restore-vs-user-action race (H1) and the settings/master + * restore ordering race (M4) are invisible to a suite that only uses it: + * `restore()` never actually suspends past a real coroutine dispatch point. + * This fake reintroduces that suspension deterministically so tests can + * interleave a user action (or a settings arrival) with an in-flight + * restore by controlling exactly when [gate] completes. + * + * [updateData] (writes) is *not* gated — production code never waits on a + * write to observe a race, and gating it would only slow tests down. + */ +class SlowDataStore( + private val delegate: DataStore, + private val gate: CompletableDeferred, +) : DataStore { + + override val data: Flow = flow { + gate.await() + emitAll(delegate.data) + } + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences, + ): Preferences = delegate.updateData(transform) +} diff --git a/android/app/src/test/java/com/miti99/loto/ThrowingDataStore.kt b/android/app/src/test/java/com/miti99/loto/ThrowingDataStore.kt new file mode 100644 index 0000000..d11cde5 --- /dev/null +++ b/android/app/src/test/java/com/miti99/loto/ThrowingDataStore.kt @@ -0,0 +1,34 @@ +package com.miti99.loto + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import java.io.IOException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * [DataStore] fake that fails every read and write with [exception] — the + * unit-test stand-in for a full disk, a revoked storage permission, or a + * corrupted-beyond-recovery backing file (file-backed `DataStore` cannot be + * exercised on a Windows JVM at all, per [InMemoryDataStore]'s doc). + * Exercises the L4 fallback paths in + * `com.miti99.loto.state.GameStateRepository` and + * `com.miti99.loto.settings.SettingsRepository`: both must degrade to + * defaults/empty state instead of propagating, and now also log the + * failure via `android.util.Log.w`. + * + * Defaults to [IOException] (the routine full-disk/permission case); pass a + * non-[IOException] (e.g. `RuntimeException`) to exercise the M4 fail-open + * path, where a non-IO read failure must still resolve to defaults instead + * of leaving the settings flow's collectors permanently cancelled. + */ +class ThrowingDataStore( + private val exception: Throwable = IOException("simulated read failure"), +) : DataStore { + + override val data: Flow = flow { throw exception } + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences, + ): Preferences = throw exception +} diff --git a/android/app/src/test/java/com/miti99/loto/audio/FakeVoicePlayer.kt b/android/app/src/test/java/com/miti99/loto/audio/FakeVoicePlayer.kt index 4d407c3..b871a36 100644 --- a/android/app/src/test/java/com/miti99/loto/audio/FakeVoicePlayer.kt +++ b/android/app/src/test/java/com/miti99/loto/audio/FakeVoicePlayer.kt @@ -17,12 +17,16 @@ class FakeVoicePlayer : VoicePlayerApi { var released = false private set + var cancelCount = 0 + private set + override fun speak(clipNames: List) { utterances.add(clipNames) active = clipNames } override fun cancel() { + cancelCount++ active = null } diff --git a/android/app/src/test/java/com/miti99/loto/audio/VoicePlayerHolderTest.kt b/android/app/src/test/java/com/miti99/loto/audio/VoicePlayerHolderTest.kt new file mode 100644 index 0000000..3b873f9 --- /dev/null +++ b/android/app/src/test/java/com/miti99/loto/audio/VoicePlayerHolderTest.kt @@ -0,0 +1,60 @@ +package com.miti99.loto.audio + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * H1: MainActivity.onDestroy() releases the app-scoped voice player on + * `isFinishing`, but `finish()` does not guarantee the process dies — + * Android can relaunch into the same cached process. These cover the + * holder's recreate-after-release contract in isolation, since + * `LotoApplication`/`ExoVoicePlayer` need a real Android `Context` and + * cannot be constructed in a plain JVM unit test. + */ +class VoicePlayerHolderTest { + + @Test + fun `value builds lazily and reuses the same instance across accesses`() { + var buildCount = 0 + val holder = VoicePlayerHolder { buildCount++; FakeVoicePlayer() } + + assertEquals(0, buildCount) + val first = holder.value + assertEquals(1, buildCount) + assertSame(first, holder.value) + assertEquals(1, buildCount) + } + + @Test + fun `release stops the current instance and clears the reference`() { + val holder = VoicePlayerHolder { FakeVoicePlayer() } + val first = holder.value as FakeVoicePlayer + assertFalse(first.released) + + holder.release() + assertTrue(first.released) + } + + @Test + fun `release with no instance built is a no-op`() { + val holder = VoicePlayerHolder { FakeVoicePlayer() } + holder.release() // must not throw despite value never having been read + } + + @Test + fun `a relaunch in the same process rebuilds a working player instead of the released one`() { + val holder = VoicePlayerHolder { FakeVoicePlayer() } + val first = holder.value as FakeVoicePlayer + holder.release() + + // Simulates finish() + Android reusing the cached process/Application + // instance: the next access must not hand back the terminal player. + val second = holder.value as FakeVoicePlayer + assertNotSame(first, second) + assertFalse(second.released) + } +} diff --git a/android/app/src/test/java/com/miti99/loto/settings/SettingsRepositoryTest.kt b/android/app/src/test/java/com/miti99/loto/settings/SettingsRepositoryTest.kt index 080d7dd..ab870d2 100644 --- a/android/app/src/test/java/com/miti99/loto/settings/SettingsRepositoryTest.kt +++ b/android/app/src/test/java/com/miti99/loto/settings/SettingsRepositoryTest.kt @@ -7,7 +7,14 @@ import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import com.miti99.loto.InMemoryDataStore +import com.miti99.loto.SlowDataStore +import com.miti99.loto.ThrowingDataStore +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test @@ -18,6 +25,7 @@ import org.junit.Test * counterpart: DataStore keys are typed, so a wrong-typed value is * unrepresentable — range/allowlist violations are what remain. */ +@OptIn(ExperimentalCoroutinesApi::class) class SettingsRepositoryTest { private val voiceIds = setOf("hoai-my", "nam-minh") @@ -186,4 +194,60 @@ class SettingsRepositoryTest { assertEquals(ThemeSetting.AUTO, s.theme) assertEquals(AppMode.PLAYER, s.mode) } + + @Test + fun `IO failure on read falls back to defaults instead of throwing (L4)`() = runTest { + val repository = repo(ThrowingDataStore()) + val s = repository.settingsFlow.first() + assertEquals("#7030A0", s.emptyCellColor) + assertEquals(ThemeSetting.AUTO, s.theme) + assertEquals(AppMode.PLAYER, s.mode) + } + + @Test + fun `IO failure on write is swallowed instead of throwing (L4)`() = runTest { + val repository = repo(ThrowingDataStore()) + // Must not throw even though every underlying write fails. + repository.setEmptyCellColor("#112233") + } + + @Test + fun `non-IO failure on read still falls back to defaults instead of cancelling the flow (M4)`() = + runTest { + // Before M4's fix, only IOException was caught here; anything + // else re-threw and cancelled every collector of settingsFlow + // forever, leaving `loaded` stuck at false and auto-cross dead + // for the rest of the process. + val repository = repo(ThrowingDataStore(RuntimeException("boom"))) + val s = repository.settingsFlow.first() + assertEquals("#7030A0", s.emptyCellColor) + assertEquals(AppMode.PLAYER, s.mode) + } + + @Test + fun `loaded still flips true after a non-IO read failure (M4)`() = runTest { + val repository = repo(ThrowingDataStore(RuntimeException("boom"))) + val states = mutableListOf() + backgroundScope.launch { repository.loaded.toList(states) } + runCurrent() + assertEquals(listOf(false, true), states) + } + + @Test + fun `loaded stays false until the first DataStore read resolves`() = runTest { + // M4 residual: consumers (PlayerBoardViewModel) need to tell "the + // read hasn't landed yet" apart from "it landed and is really + // PLAYER" — SlowDataStore reproduces the real async gap that + // InMemoryDataStore alone resolves synchronously. + val gate = CompletableDeferred() + val repository = repo(SlowDataStore(InMemoryDataStore(), gate)) + val states = mutableListOf() + backgroundScope.launch { repository.loaded.toList(states) } + runCurrent() + assertEquals(listOf(false), states) + + gate.complete(Unit) + runCurrent() + assertEquals(listOf(false, true), states) + } } diff --git a/android/app/src/test/java/com/miti99/loto/state/GameStateRepositoryTest.kt b/android/app/src/test/java/com/miti99/loto/state/GameStateRepositoryTest.kt index e1a5f99..049dda2 100644 --- a/android/app/src/test/java/com/miti99/loto/state/GameStateRepositoryTest.kt +++ b/android/app/src/test/java/com/miti99/loto/state/GameStateRepositoryTest.kt @@ -3,6 +3,7 @@ package com.miti99.loto.state import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import com.miti99.loto.InMemoryDataStore +import com.miti99.loto.ThrowingDataStore import com.miti99.loto.game.CardGenerator import kotlin.random.Random import kotlinx.coroutines.test.runTest @@ -34,7 +35,12 @@ class GameStateRepositoryTest { @Test fun `master state survives a repository recreation`() = runTest { val store = InMemoryDataStore() - val state = MasterRoundState(called = listOf(5, 88, 12), remaining = listOf(1, 2, 3)) + // L3: called/remaining must partition the full 90-number deck, so + // this fixture (unlike a handful of arbitrary numbers) reflects a + // real mid-game snapshot. + val called = listOf(5, 88, 12) + val remaining = (1..90).filterNot { it in called } + val state = MasterRoundState(called = called, remaining = remaining) GameStateRepository(store).saveMasterState(state) assertEquals(state, GameStateRepository(store).loadMasterState()) } @@ -77,4 +83,72 @@ class GameStateRepositoryTest { assertEquals(grid, restored?.grid) assertEquals(grid.map { r -> r.map { false } }, restored?.crossed) } + + @Test + fun `duplicate number in the grid falls back to no saved card (L3)`() = runTest { + val store = InMemoryDataStore() + val repo = GameStateRepository(store) + val nums = MutableList(81) { 0 } + nums[0] = 5 + nums[1] = 5 // duplicate non-zero value — never legal in a real card + store.edit { it[stringPreferencesKey("player_grid")] = nums.joinToString(",") } + assertNull(repo.loadPlayerState()) + } + + @Test + fun `duplicate manual unticks fall back to no unticks rather than silently deduping (L3)`() = + runTest { + val store = InMemoryDataStore() + val repo = GameStateRepository(store) + val grid = CardGenerator.generateGrid(Random(3)) + repo.savePlayerState(PlayerRoundState(grid, grid.map { r -> r.map { false } }, emptySet())) + store.edit { it[stringPreferencesKey("player_manual_unticks")] = "7,7,42" } + val restored = repo.loadPlayerState() + assertEquals(grid, restored?.grid) + assertEquals(emptySet(), restored?.manualUnticks) + } + + @Test + fun `shape-invalid master deck falls back to no saved round (L3)`() = runTest { + val store = InMemoryDataStore() + val repo = GameStateRepository(store) + + // Duplicate within one field (already covered by parseNumberList, + // reconfirmed here at the loadMasterState level). + store.edit { + it[stringPreferencesKey("master_called")] = "1,1,2" + it[stringPreferencesKey("master_remaining")] = (3..90).joinToString(",") + } + assertNull(repo.loadMasterState()) + + // Overlap between called and remaining (5..10 appear in both). + store.edit { + it[stringPreferencesKey("master_called")] = (1..10).joinToString(",") + it[stringPreferencesKey("master_remaining")] = (5..90).joinToString(",") + } + assertNull(repo.loadMasterState()) + + // Valid individually, but doesn't add up to a full 90-number deck. + store.edit { + it[stringPreferencesKey("master_called")] = "1,2,3" + it[stringPreferencesKey("master_remaining")] = "4,5,6" + } + assertNull(repo.loadMasterState()) + } + + @Test + fun `IO failures on read fall back to empty state instead of throwing (L4)`() = runTest { + val repo = GameStateRepository(ThrowingDataStore()) + assertNull(repo.loadPlayerState()) + assertNull(repo.loadMasterState()) + } + + @Test + fun `IO failures on write are swallowed instead of throwing (L4)`() = runTest { + val repo = GameStateRepository(ThrowingDataStore()) + val grid = CardGenerator.generateGrid(Random(4)) + // Must not throw even though every underlying write fails. + repo.savePlayerState(PlayerRoundState(grid, grid.map { r -> r.map { false } }, emptySet())) + repo.saveMasterState(MasterRoundState(called = emptyList(), remaining = (1..90).toList())) + } } diff --git a/android/app/src/test/java/com/miti99/loto/state/MasterPanelViewModelTest.kt b/android/app/src/test/java/com/miti99/loto/state/MasterPanelViewModelTest.kt index 5a21e3a..a1f4673 100644 --- a/android/app/src/test/java/com/miti99/loto/state/MasterPanelViewModelTest.kt +++ b/android/app/src/test/java/com/miti99/loto/state/MasterPanelViewModelTest.kt @@ -93,7 +93,7 @@ class MasterPanelViewModelTest { } @Test - fun `auto-call stops when the deck is exhausted`() = runTest(dispatcher) { + fun `auto-call stops immediately when a draw exhausts the deck (L5)`() = runTest(dispatcher) { val env = env(autoSettings) env.repository.saveMasterState( MasterRoundState(called = (1..89).toList(), remaining = listOf(90)), @@ -104,10 +104,32 @@ class MasterPanelViewModelTest { advanceTimeBy(2001) assertEquals(90, env.masterStore.state.value.called.size) + // L5: the draw that exhausts the deck must flip autoRunning off + // right away (inside drawNext()) rather than waiting for a next + // tick that never comes once the "Dừng" button is hidden by the + // UI's remaining-empty guard — and toggleAuto() can no longer + // reach it either, since it early-returns while remaining is + // empty. + assertFalse(env.viewModel.autoRunning.value) + // Stays false — no spurious re-arm on a later tick. advanceTimeBy(2001) assertFalse(env.viewModel.autoRunning.value) } + @Test + fun `manual drawNext exhausting the deck leaves autoRunning false (L5)`() = + runTest(dispatcher) { + val env = env(autoSettings.copy(autoCallEnabled = false)) + env.repository.saveMasterState( + MasterRoundState(called = (1..89).toList(), remaining = listOf(90)), + ) + env.masterStore.restore() + env.viewModel.drawNext() // manual "Xổ số" draw, no auto-call involved + runCurrent() + assertEquals(90, env.masterStore.state.value.called.size) + assertFalse(env.viewModel.autoRunning.value) + } + @Test fun `disabling the auto setting mid-run stops the run`() = runTest(dispatcher) { val env = env(autoSettings) diff --git a/android/app/src/test/java/com/miti99/loto/state/MasterStoreTest.kt b/android/app/src/test/java/com/miti99/loto/state/MasterStoreTest.kt new file mode 100644 index 0000000..8ce0fe6 --- /dev/null +++ b/android/app/src/test/java/com/miti99/loto/state/MasterStoreTest.kt @@ -0,0 +1,90 @@ +package com.miti99.loto.state + +import com.miti99.loto.InMemoryDataStore +import com.miti99.loto.SlowDataStore +import com.miti99.loto.game.DrawDeck +import kotlin.random.Random +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +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.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Covers H1 on the master side: `MasterStore.restore()` suspends on the + * DataStore read the same way `PlayerBoardViewModel.restore()` does, and + * "Ván mới" (`startNewGame()`) can land while that read is still in flight. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MasterStoreTest { + + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `loading starts true and flips false once restore settles`() = runTest(dispatcher) { + val store = MasterStore(GameStateRepository(InMemoryDataStore()), backgroundScope, DrawDeck(Random(1))) + assertTrue(store.loading.value) + store.restore() + assertFalse(store.loading.value) + } + + @Test + fun `restore does not clobber a new game started while the load is still in flight`() = + runTest(dispatcher) { + // Seed a previously-saved round the same way a prior app run would. + val backing = InMemoryDataStore() + val seedRepo = GameStateRepository(backing) + seedRepo.saveMasterState( + MasterRoundState(called = (1..10).toList(), remaining = (11..90).toList()), + ) + runCurrent() + + val gate = CompletableDeferred() + val repository = GameStateRepository(SlowDataStore(backing, gate)) + val store = MasterStore(repository, backgroundScope, DrawDeck(Random(7))) + + val restoreJob = launch { store.restore() } + runCurrent() // restore() is now suspended on gate.await() + assertTrue(store.loading.value) + assertEquals(emptyList(), store.state.value.called) + + // "Ván mới" lands while the restore is still in flight (the same + // race PlayerBoardScreen's generate button has, gated by + // MasterPanelViewModel.loading in production). + store.startNewGame() + runCurrent() + assertEquals(emptyList(), store.state.value.called) + assertEquals(90, store.state.value.remaining.size) + + // Let the stale restore resume and resolve. + gate.complete(Unit) + restoreJob.join() + runCurrent() + + // The new game must survive — restore must not overwrite it with + // the stale persisted round (H1). + assertEquals(emptyList(), store.state.value.called) + assertEquals(90, store.state.value.remaining.size) + assertFalse(store.loading.value) + } +} diff --git a/android/app/src/test/java/com/miti99/loto/state/PlayerBoardViewModelTest.kt b/android/app/src/test/java/com/miti99/loto/state/PlayerBoardViewModelTest.kt index df4722f..30b7b11 100644 --- a/android/app/src/test/java/com/miti99/loto/state/PlayerBoardViewModelTest.kt +++ b/android/app/src/test/java/com/miti99/loto/state/PlayerBoardViewModelTest.kt @@ -1,13 +1,21 @@ package com.miti99.loto.state import com.miti99.loto.InMemoryDataStore +import com.miti99.loto.SlowDataStore import com.miti99.loto.audio.FakeVoicePlayer +import com.miti99.loto.game.CardGenerator import com.miti99.loto.settings.AppMode import com.miti99.loto.settings.Settings +import com.miti99.loto.settings.SettingsRepository import kotlin.random.Random +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy @@ -19,6 +27,7 @@ import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -54,7 +63,11 @@ class PlayerBoardViewModelTest { MasterStore(repository, backgroundScope, com.miti99.loto.game.DrawDeck(Random(7))) val settings = MutableStateFlow(initial) val voice = FakeVoicePlayer() - val viewModel = PlayerBoardViewModel(repository, masterStore, settings, voice) + // Reuse `dispatcher` for generate()'s compute step too (M5), so it + // shares the virtual clock `runCurrent()`/`advanceUntilIdle()` + // drive instead of racing a real background thread. + val viewModel = + PlayerBoardViewModel(repository, masterStore, settings, voice, dispatcher) runCurrent() // run restore + start the master collector return Env(repository, masterStore, settings, voice, viewModel) } @@ -89,7 +102,7 @@ class PlayerBoardViewModelTest { } runCurrent() val waitingNumber = cells[4].second - assertEquals("Chờ $waitingNumber", env.viewModel.uiState.value.toast) + assertEquals(waitingNumber, env.viewModel.uiState.value.waitingNumber) // voiceWaitingNumber off → only the "cho" clip. assertEquals(listOf("cho"), env.voice.utterances.last()) // The awaited cell pulses and its section rings. @@ -107,9 +120,9 @@ class PlayerBoardViewModelTest { runCurrent() rowCells(env, 0).take(4).forEach { (col, _) -> env.viewModel.onCellClick(0, col) } runCurrent() - assertNotNull(env.viewModel.uiState.value.toast) + assertNotNull(env.viewModel.uiState.value.waitingNumber) advanceTimeBy(5001) - assertNull(env.viewModel.uiState.value.toast) + assertNull(env.viewModel.uiState.value.waitingNumber) } @Test @@ -133,7 +146,7 @@ class PlayerBoardViewModelTest { runCurrent() rowCells(env, 0).take(4).forEach { (col, _) -> env.viewModel.onCellClick(0, col) } runCurrent() - assertNotNull(env.viewModel.uiState.value.toast) + assertNotNull(env.viewModel.uiState.value.waitingNumber) assertEquals(0, env.voice.utterances.size) } @@ -212,7 +225,7 @@ class PlayerBoardViewModelTest { val restored = PlayerBoardViewModel(env.repository, env.masterStore, env.settings, env.voice) advanceUntilIdle() assertEquals(announced, env.voice.utterances.size) - assertNull(restored.uiState.value.toast) + assertNull(restored.uiState.value.waitingNumber) } @Test @@ -283,6 +296,32 @@ class PlayerBoardViewModelTest { assertEquals(listOf("kinh"), env.voice.utterances.last()) } + @Test + fun `manual unticks are cleared and persisted on a master Ván mới outside both mode (L6)`() = + runTest(dispatcher) { + val env = env(playerVoiceOn) // mode defaults to PLAYER (non-BOTH) + env.viewModel.generate() + runCurrent() + env.masterStore.startNewGame() + repeat(90) { env.masterStore.drawNext() } // every board number is now "called" + runCurrent() + + val grid = env.viewModel.uiState.value.grid!! + val col = grid[0].indexOfFirst { it > 0 } + env.viewModel.onCellClick(0, col) // cross + env.viewModel.onCellClick(0, col) // untick -> manualUnticks gains this number + advanceUntilIdle() + assertEquals(setOf(grid[0][col]), env.repository.loadPlayerState()?.manualUnticks) + + // Master resets the round while still in PLAYER mode — the + // reset branch used to clear manualUnticks only when mode was + // BOTH. + env.masterStore.startNewGame() + advanceUntilIdle() + + assertEquals(emptySet(), env.repository.loadPlayerState()?.manualUnticks) + } + @Test fun `clearMarks in both mode re-crosses all called numbers (unticks wiped)`() = runTest(dispatcher) { @@ -303,4 +342,217 @@ class PlayerBoardViewModelTest { runCurrent() assertTrue(env.viewModel.uiState.value.crossed[0][col]) } + + @Test + fun `loading flips false once restore settles, gating the generate button`() = + runTest(dispatcher) { + // env() already runs restore() to completion via runCurrent(); + // this asserts that side effect explicitly rather than assuming it. + val env = env(playerVoiceOn) + assertFalse(env.viewModel.uiState.value.loading) + } + + @Test + fun `restore does not clobber a card generated while the load is still in flight`() = + runTest(dispatcher) { + // Seed a previously-saved round the same way a prior app run would. + val backing = InMemoryDataStore() + val seedRepo = GameStateRepository(backing) + val savedGrid = CardGenerator.generateGrid(Random(1)) + seedRepo.savePlayerState( + PlayerRoundState(savedGrid, savedGrid.map { row -> row.map { false } }, emptySet()), + ) + runCurrent() + + // The next "process" reads through a DataStore whose first + // emission is gated — simulating the real async DataStore read + // that H1 is about, instead of InMemoryDataStore's synchronous one. + val gate = CompletableDeferred() + val repository = GameStateRepository(SlowDataStore(backing, gate)) + val masterStore = + MasterStore(repository, backgroundScope, com.miti99.loto.game.DrawDeck(Random(7))) + val settings = MutableStateFlow(playerVoiceOn) + val voice = FakeVoicePlayer() + val viewModel = PlayerBoardViewModel(repository, masterStore, settings, voice, dispatcher) + runCurrent() // restore() is now suspended on gate.await() + assertTrue(viewModel.uiState.value.loading) + assertNull(viewModel.uiState.value.grid) + + // A user action lands while the restore is still in flight. + viewModel.generate() + runCurrent() + val freshGrid = viewModel.uiState.value.grid + assertNotNull(freshGrid) + assertNotEquals(savedGrid, freshGrid) + + // Let the stale restore resume and resolve. + gate.complete(Unit) + runCurrent() + + // The fresh card must survive — restore must not silently + // overwrite it with the stale persisted round (H1). + assertEquals(freshGrid, viewModel.uiState.value.grid) + assertFalse(viewModel.uiState.value.loading) + } + + @Test + fun `a settings arrival that lands before masterStore's restore still gets the full replay`() = + runTest(dispatcher) { + // M4: masterStore.restore() and the settings DataStore read are + // two independent async reads. This covers the ordering where + // settings resolves to its real (persisted) value before + // masterStore.restore() lands — combine(masterStore.state, + // settings) re-runs the replay on the settings change, so the + // mode is already correct by the time the master history first + // arrives. (The reverse ordering — masterStore's restore landing + // with real history *before* settings ever resolves — is + // covered separately below, M3/M4 residual.) + val repository = GameStateRepository(InMemoryDataStore()) + val masterStore = + MasterStore(repository, backgroundScope, com.miti99.loto.game.DrawDeck(Random(7))) + // masterStore.restore() is deliberately NOT called yet. + val settings = MutableStateFlow(playerVoiceOn.copy(mode = AppMode.PLAYER)) + val voice = FakeVoicePlayer() + val viewModel = PlayerBoardViewModel(repository, masterStore, settings, voice, dispatcher) + runCurrent() + viewModel.generate() + runCurrent() + + // The real persisted mode lands while masterStore still reads + // as empty (its own restore has not resolved yet). + settings.value = settings.value.copy(mode = AppMode.BOTH) + runCurrent() + assertTrue(viewModel.uiState.value.crossed.flatten().none { it }) + + // masterStore's restore resolves afterwards with the full + // history — this is the first time that history is evaluated, + // and mode is already BOTH. + repository.saveMasterState(MasterRoundState(called = (1..90).toList(), remaining = emptyList())) + masterStore.restore() + runCurrent() + + assertEquals(List(9) { true }, viewModel.uiState.value.rowComplete) + } + + @Test + fun `master restore landing with full history before settings resolves still replays under the real mode (M4 residual)`() = + runTest(dispatcher) { + // masterStore.restore() resolves synchronously (InMemoryDataStore) + // with the full called history while the settings DataStore read + // is still in flight (SlowDataStore) — and, unlike an earlier + // version of this test, generate() is tapped only *after* that + // master history has already landed. That is the ordering M3 + // says a generate()-before-restore() tap dodges: without the + // settingsOrNull guard, generate() would compute its auto-cross + // cursor against the still-null (placeholder) mode, silently + // discard the already-available BOTH-mode history via + // `lastHandledIndex = called.size`, and the replay would be lost + // for good once settings resolved (masterStore.state does not + // re-emit on its own). + val repository = GameStateRepository(InMemoryDataStore()) + val masterStore = + MasterStore(repository, backgroundScope, com.miti99.loto.game.DrawDeck(Random(7))) + repository.saveMasterState( + MasterRoundState(called = (1..90).toList(), remaining = emptyList()), + ) + + val voiceIds = setOf("hoai-my") + val settingsBacking = InMemoryDataStore() + // Persist the real mode ahead of time (BOTH != the PLAYER + // default) via an ungated repository over the same backing store. + SettingsRepository(settingsBacking, voiceIds, "hoai-my").setMode(AppMode.BOTH) + + val gate = CompletableDeferred() + val gatedSettingsRepo = + SettingsRepository(SlowDataStore(settingsBacking, gate), voiceIds, "hoai-my") + // Single source of truth (M2), matching LotoApplication's + // settingsOrNull wiring: null until the gated read resolves. + val settingsOrNull: StateFlow = gatedSettingsRepo.settingsFlow + .map { it } + .stateIn(backgroundScope, SharingStarted.Eagerly, null) + val voice = FakeVoicePlayer() + + val viewModel = + PlayerBoardViewModel(repository, masterStore, settingsOrNull, voice, dispatcher) + + // Master's restore lands with the full history first, while + // settings is still gated. + masterStore.restore() + runCurrent() + assertTrue(viewModel.uiState.value.loading) + + // A tap in this window — the UI's button is disabled on + // `loading`, but generate() guards itself too in case a caller + // acts before that gate resolves (M3) — must not consume the + // already-available history under the wrong mode. + viewModel.generate() + runCurrent() + assertNull(viewModel.uiState.value.grid) + + // Settings resolves afterwards to the real, persisted BOTH mode. + gate.complete(Unit) + runCurrent() + assertFalse(viewModel.uiState.value.loading) + + // The tap the user actually makes once the button re-enables. + viewModel.generate() + runCurrent() + + assertEquals(List(9) { true }, viewModel.uiState.value.rowComplete) + } + + @Test + fun `clearMarks() is also a no-op while settings have not resolved yet (M3)`() = + runTest(dispatcher) { + val repository = GameStateRepository(InMemoryDataStore()) + val masterStore = + MasterStore(repository, backgroundScope, com.miti99.loto.game.DrawDeck(Random(7))) + // A persisted round makes `grid` non-null independently of + // settings, so clearMarks()'s own `grid ?: return` guard alone + // would not catch this window. + val savedGrid = CardGenerator.generateGrid(Random(3)) + repository.savePlayerState( + PlayerRoundState(savedGrid, savedGrid.map { row -> row.map { true } }, emptySet()), + ) + val settingsOrNull = MutableStateFlow(null) + val voice = FakeVoicePlayer() + val viewModel = + PlayerBoardViewModel(repository, masterStore, settingsOrNull, voice, dispatcher) + runCurrent() // restore() resolves; settingsOrNull is still null + + val before = viewModel.uiState.value.crossed + viewModel.clearMarks() + runCurrent() + assertEquals(before, viewModel.uiState.value.crossed) + } + + @Test + fun `loading stays true while settings have not resolved even after restore settles, and flips false once they do (M3)`() = + runTest(dispatcher) { + val repository = GameStateRepository(InMemoryDataStore()) + val masterStore = + MasterStore(repository, backgroundScope, com.miti99.loto.game.DrawDeck(Random(7))) + val settingsOrNull = MutableStateFlow(null) + val voice = FakeVoicePlayer() + val viewModel = + PlayerBoardViewModel(repository, masterStore, settingsOrNull, voice, dispatcher) + runCurrent() // restore() resolves (InMemoryDataStore is synchronous) + + assertTrue(viewModel.uiState.value.loading) + + settingsOrNull.value = playerVoiceOn + runCurrent() + assertFalse(viewModel.uiState.value.loading) + } + + @Test + fun `a second rapid generate() tap while the first is still computing is a no-op (L-a)`() = + runTest(dispatcher) { + val env = env(playerVoiceOn) + env.viewModel.generate() + env.viewModel.generate() // enqueued before the first call's compute step ran + runCurrent() + assertEquals(1, env.voice.cancelCount) + assertNotNull(env.viewModel.uiState.value.grid) + } }