fix(android): restore/user-action race, confetti-in-dialog, settings-loaded gate, color rounding, backup rules, manifest updates

This commit is contained in:
2026-09-01 00:11:29 +07:00
parent 0593672afd
commit 79b26a5af3
29 changed files with 1246 additions and 142 deletions
+15 -2
View File
@@ -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 {
+2 -1
View File
@@ -35,6 +35,8 @@
<application
android:name=".LotoApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -45,7 +47,6 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Theme.Loto.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -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<Settings> 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<Settings?> by lazy {
settingsRepository.settingsFlow
.map<Settings, Settings?> { it }
.stateIn(appScope, SharingStarted.Eagerly, null)
}
/** App-scoped settings snapshot every other ViewModel/Compose consumer reads from. */
val settingsState: StateFlow<Settings> 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 }
}
@@ -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)
@@ -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
}
}
@@ -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<Settings> = 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<Boolean> = 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"
}
}
@@ -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<Preferences>) {
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<Preferences>) {
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<Preferences>) {
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<Preferences>) {
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<Int>? {
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<Int>, remaining: List<Int>): Boolean {
val total = called.size + remaining.size
if (total != DECK_SIZE) return false
val combined = HashSet<Int>(total)
combined.addAll(called)
combined.addAll(remaining)
return combined.size == total
}
private companion object {
const val DECK_SIZE = 90
const val TAG = "GameStateRepository"
}
}
@@ -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) ->
@@ -28,6 +28,9 @@ class MasterPanelViewModel(
/** Master round data (called history + remaining deck). */
val masterState: StateFlow<MasterRoundState> = masterStore.state
/** True until the startup restore has resolved; gates "Ván mới" (H1). */
val loading: StateFlow<Boolean> = masterStore.loading
private val _autoRunning = MutableStateFlow(false)
val autoRunning: StateFlow<Boolean> = _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()
}
}
@@ -21,15 +21,29 @@ class MasterStore(
private val _state = MutableStateFlow(MasterRoundState(emptyList(), emptyList()))
val state: StateFlow<MasterRoundState> = _state
private val _loading = MutableStateFlow(true)
/** True until [restore] has resolved once at startup; drives button gating. */
val loading: StateFlow<Boolean> = _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
}
@@ -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<PlayerCard.Cell> = emptySet(),
/** Per 3-row section (Tân Tân card bands): any row inside is waiting. */
val sectionWaiting: List<Boolean> = 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<Settings>,
/**
* 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<Settings?>,
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<Settings>`) 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<PlayerUiState> = _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<List<Int>>) {
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,
)
}
@@ -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)
}
@@ -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),
) {
@@ -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()
}
}
}
@@ -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) {
@@ -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)
}
},
) {
@@ -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<String?>(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
+3 -4
View File
@@ -11,9 +11,10 @@
<string name="player_empty_hint_prefix">Nhấn</string>
<string name="player_empty_hint_suffix">để bắt đầu chơi</string>
<string name="player_empty_wish">🎫 Chúc cả nhà một ván vui vẻ</string>
<string name="player_board_label">Bảng lô tô</string>
<string name="toast_waiting">Chờ %1$d</string>
<string name="toast_dismiss">Đóng thông báo</string>
<string name="player_cell_number">Số %1$d</string>
<string name="player_cell_crossed_suffix">, đã đánh dấu</string>
<string name="player_cell_waiting_suffix">, đang chờ</string>
<!-- Section bands (Tân Tân physical card) -->
<string name="section_label_1">Lô tô</string>
@@ -26,7 +27,6 @@
<string name="kinh_row_suffix">đã đầy đủ!</string>
<string name="kinh_shout">Hãy hô to "Kinh!" 🎶</string>
<string name="kinh_dismiss">Tuyệt vời! 🥳</string>
<string name="kinh_close">Đóng</string>
<!-- Master panel -->
<string name="master_heading">Quản trò</string>
@@ -39,7 +39,6 @@
<string name="master_last_called">Số vừa xổ</string>
<string name="master_called_count">Đã xổ: %1$d/90 · Còn lại: %2$d</string>
<string name="master_call_order">Thứ tự đã xổ:</string>
<string name="master_board_label">Bảng theo dõi số đã xổ</string>
<string name="master_countdown">Đếm ngược: %1$d giây</string>
<string name="master_empty_badge">Chế độ Quản trò</string>
<string name="master_empty_hint_prefix">Nhấn</string>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Pre-API 31 counterpart of data_extraction_rules.xml (L9): android:fullBackupContent
is what the platform reads on API 24-30, where android:dataExtractionRules
does not apply. Same exclusions, same rationale — see that file's comment.
-->
<full-backup-content>
<exclude domain="file" path="datastore/loto_game_state.preferences_pb" />
<exclude domain="file" path="datastore/loto_game_state.preferences_pb.tmp" />
</full-backup-content>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
API 31+ backup/transfer rules (L9). The round-state DataStore
(`loto_game_state`, written by GameStateRepository via the
`gameStateDataStore` delegate in LotoApplication.kt) is device-local and
transient — a fairground round restored mid-game on another device is
confusing, not useful. The settings DataStore (`loto_settings`) is
intentionally NOT excluded here: those preferences are worth carrying
over on cloud restore and device-to-device transfer.
On-disk path verified from androidx.datastore.preferences.preferencesDataStore's
default file location: files/datastore/<name>.preferences_pb. DataStore
writes through a `.tmp` sibling of that same name before the atomic
rename; a backup taken mid-write can capture that sibling instead of (or
alongside) the final file, so it is excluded too.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="file" path="datastore/loto_game_state.preferences_pb" />
<exclude domain="file" path="datastore/loto_game_state.preferences_pb.tmp" />
</cloud-backup>
<device-transfer>
<exclude domain="file" path="datastore/loto_game_state.preferences_pb" />
<exclude domain="file" path="datastore/loto_game_state.preferences_pb.tmp" />
</device-transfer>
</data-extraction-rules>
@@ -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<Preferences>,
private val gate: CompletableDeferred<Unit>,
) : DataStore<Preferences> {
override val data: Flow<Preferences> = flow {
gate.await()
emitAll(delegate.data)
}
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences = delegate.updateData(transform)
}
@@ -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<Preferences> {
override val data: Flow<Preferences> = flow { throw exception }
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences = throw exception
}
@@ -17,12 +17,16 @@ class FakeVoicePlayer : VoicePlayerApi {
var released = false
private set
var cancelCount = 0
private set
override fun speak(clipNames: List<String>) {
utterances.add(clipNames)
active = clipNames
}
override fun cancel() {
cancelCount++
active = null
}
@@ -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)
}
}
@@ -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<Boolean>()
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<Unit>()
val repository = repo(SlowDataStore(InMemoryDataStore(), gate))
val states = mutableListOf<Boolean>()
backgroundScope.launch { repository.loaded.toList(states) }
runCurrent()
assertEquals(listOf(false), states)
gate.complete(Unit)
runCurrent()
assertEquals(listOf(false, true), states)
}
}
@@ -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<Int>(), 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()))
}
}
@@ -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)
@@ -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<Unit>()
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<Int>(), 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<Int>(), 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<Int>(), store.state.value.called)
assertEquals(90, store.state.value.remaining.size)
assertFalse(store.loading.value)
}
}
@@ -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<Int>(), 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<Unit>()
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<Unit>()
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<Settings?> = gatedSettingsRepo.settingsFlow
.map<Settings, Settings?> { 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<Settings?>(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<Settings?>(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)
}
}