mirror of
https://github.com/tiennm99/loto.git
synced 2026-09-03 14:18:25 +00:00
fix(android): restore/user-action race, confetti-in-dialog, settings-loaded gate, color rounding, backup rules, manifest updates
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user