diff --git a/CHANGELOG.md b/CHANGELOG.md index 10547fa..ec7ba0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- History and Round Detail on real rounds (cu-10 / cu-11): the stats + layer wires the persisted `game_round` ledger into the screens that + were on preview data. `HistoryViewModel` maps `GameRepository.rounds` + into the list plus the aggregate summary (rounds, win rate, net), with + the search/filter still screen-local and a dedicated "no rounds yet" + empty state. `RoundDetailViewModel` resolves a round by its id + (`GameRepository.getRound`) and renders Loading / Success / NotFound; + the provably-fair block now shows the round's real serverSeed / + clientSeed / nonce / HMAC hash so the outcome is independently + verifiable. Covered by `HistoryViewModelTest`, `RoundDetailViewModelTest` + and the new `getRound` cases in `GameRepositoryImplTest`. + - Playable Coinflip (cu-09): the first game wired end-to-end through the native provably-fair engine. `CoinflipViewModel` -> `GameRepository` validates the stake against the Room wallet, draws the outcome on the diff --git a/app/src/androidTest/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipScreenTest.kt b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipScreenTest.kt index 4b6269d..31bae33 100644 --- a/app/src/androidTest/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipScreenTest.kt +++ b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/coinflip/CoinflipScreenTest.kt @@ -1,5 +1,9 @@ package com.plainstudio.stackcasino.feature.coinflip +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription @@ -17,10 +21,22 @@ class CoinflipScreenTest { @get:Rule val composeRule = createComposeRule() + /** + * Hosts the stateless screen with a local state holder so picking a + * side flows back through [CoinflipScreen]'s `onPick` callback and + * recomposes, mirroring how the ViewModel drives it in production. + */ private fun setScreen(onBack: () -> Unit = {}) { composeRule.setContent { StackcasinoTheme { - CoinflipScreen(onBack = onBack) + var state by remember { mutableStateOf(CoinflipUiState()) } + CoinflipScreen( + state = state, + onBack = onBack, + onPick = { state = state.copy(pick = it) }, + onPreset = { state = state.copy(betAmount = it) }, + onFlip = {}, + ) } } } diff --git a/app/src/androidTest/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailScreenTest.kt b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailScreenTest.kt index 8159c14..7e24b84 100644 --- a/app/src/androidTest/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailScreenTest.kt +++ b/app/src/androidTest/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailScreenTest.kt @@ -23,7 +23,7 @@ class RoundDetailScreenTest { private fun setScreen(onBack: () -> Unit = {}) { composeRule.setContent { StackcasinoTheme { - RoundDetailScreen(data = roundDetailPreviewData(), onBack = onBack) + RoundDetailScreen(state = RoundDetailUiState.Success(roundDetailPreviewData()), onBack = onBack) } } } diff --git a/app/src/main/java/com/plainstudio/stackcasino/data/game/GameRepositoryImpl.kt b/app/src/main/java/com/plainstudio/stackcasino/data/game/GameRepositoryImpl.kt index a190bf7..0b96fc2 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/data/game/GameRepositoryImpl.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/data/game/GameRepositoryImpl.kt @@ -49,6 +49,8 @@ class GameRepositoryImpl override val rounds: Flow> = dao.observeRounds().map { rows -> rows.map { it.toDomain() } } + override suspend fun getRound(id: String): GameRound? = dao.getRound(id)?.toDomain() + override suspend fun playCoinflip( pick: CoinSide, stake: Double, diff --git a/app/src/main/java/com/plainstudio/stackcasino/domain/game/GameRepository.kt b/app/src/main/java/com/plainstudio/stackcasino/domain/game/GameRepository.kt index 6c8b7e5..4bf5d2b 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/domain/game/GameRepository.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/domain/game/GameRepository.kt @@ -25,6 +25,9 @@ interface GameRepository { /** Every settled round, newest first. */ val rounds: Flow> + /** Resolves a single persisted round by id, or null if it is unknown. */ + suspend fun getRound(id: String): GameRound? + /** * Plays one coinflip round: validates the stake against the wallet, * draws the outcome on the native engine, settles the balance (x2 on diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryScreen.kt index 190b26f..455d780 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryScreen.kt @@ -103,6 +103,7 @@ fun HistoryScreen( SummaryStrip(summary = data.summary) RoundList( rounds = visibleRounds, + allRoundsEmpty = data.rounds.isEmpty(), onRoundTap = { round -> onOpenRound(round.id) }, ) Spacer(modifier = Modifier.height(BottomScrollPadding)) @@ -296,6 +297,7 @@ private fun SummaryCell( @Composable private fun RoundList( rounds: List, + allRoundsEmpty: Boolean, onRoundTap: (HistoryRound) -> Unit, ) { Column( @@ -306,7 +308,7 @@ private fun RoundList( verticalArrangement = Arrangement.spacedBy(RoundGap), ) { if (rounds.isEmpty()) { - EmptyRoundsState() + EmptyRoundsState(allRoundsEmpty = allRoundsEmpty) } else { rounds.forEach { round -> RoundCard(round = round, onClick = { onRoundTap(round) }) } EndOfResultsSentinel() @@ -458,7 +460,7 @@ private fun MetricCell( } @Composable -private fun EmptyRoundsState() { +private fun EmptyRoundsState(allRoundsEmpty: Boolean) { Box(modifier = Modifier.fillMaxWidth().padding(top = 24.dp), contentAlignment = Alignment.Center) { EmptyState( icon = { @@ -469,8 +471,13 @@ private fun EmptyRoundsState() { modifier = Modifier.size(EmptyIconSize), ) }, - title = "No rounds match", - message = "Try clearing the filters or your search query.", + title = if (allRoundsEmpty) "No rounds yet" else "No rounds match", + message = + if (allRoundsEmpty) { + "Play a game and your rounds will show up here." + } else { + "Try clearing the filters or your search query." + }, ) } } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryViewModel.kt new file mode 100644 index 0000000..7a5d5e9 --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/history/HistoryViewModel.kt @@ -0,0 +1,85 @@ +package com.plainstudio.stackcasino.feature.history + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.plainstudio.stackcasino.domain.game.GameRepository +import com.plainstudio.stackcasino.domain.game.GameRound +import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.model.RoundOutcome +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject +import kotlin.math.roundToInt + +/** + * Drives the history screen. Maps the Room-backed [GameRepository.rounds] + * stream into the [HistoryData] the screen already renders: the summary + * aggregates over every settled round and each round becomes a row. The + * search and filter state stays screen-local. + */ +@HiltViewModel +class HistoryViewModel + @Inject + constructor( + gameRepository: GameRepository, + ) : ViewModel() { + val historyData: StateFlow = + gameRepository.rounds + .map { rounds -> rounds.toHistoryData() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), EMPTY_DATA) + + private fun List.toHistoryData(): HistoryData = + HistoryData( + summary = toSummary(), + rounds = map { it.toRow() }, + ) + + private fun List.toSummary(): HistorySummary { + val wins = count { it.outcome == RoundOutcome.Win } + val net = sumOf { it.netProfit } + return HistorySummary( + totalRounds = size, + winRatePercent = if (isEmpty()) 0 else (wins * PERCENT / size.toDouble()).roundToInt(), + netLabel = signedMoney(net), + ) + } + + private fun GameRound.toRow(): HistoryRound = + HistoryRound( + id = id, + game = game, + timestampLabel = TIMESTAMP_FORMAT.format(Date(timestamp)), + betLabel = "$" + formatMoney(stake), + payoutLabel = "$" + formatMoney(payout), + multiplierLabel = if (game == GameKey.Coinflip) pick else multiplierLabel(), + outcome = outcome, + ) + + private fun GameRound.multiplierLabel(): String { + val isWhole = multiplier % 1.0 == 0.0 + val rounded = if (isWhole) multiplier.toInt().toString() else String.format(Locale.US, "%.2f", multiplier) + return rounded + "x" + } + + private fun formatMoney(value: Double): String = String.format(Locale.US, "%,.2f", value) + + private fun signedMoney(value: Double): String = + (if (value < 0) "-$" else "+$") + formatMoney(kotlin.math.abs(value)) + + private companion object { + const val STOP_TIMEOUT_MILLIS = 5_000L + const val PERCENT = 100 + val TIMESTAMP_FORMAT = SimpleDateFormat("M/d/yyyy · h:mm a", Locale.US) + val EMPTY_DATA = + HistoryData( + summary = HistorySummary(totalRounds = 0, winRatePercent = 0, netLabel = "+$0.00"), + rounds = emptyList(), + ) + } + } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailScreen.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailScreen.kt index eade8c0..54e6aa4 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailScreen.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowLeft import androidx.compose.material.icons.outlined.IosShare +import androidx.compose.material.icons.outlined.SearchOff import androidx.compose.material.icons.outlined.Shield import androidx.compose.material3.Icon import androidx.compose.material3.Surface @@ -36,6 +37,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.plainstudio.stackcasino.model.RoundOutcome +import com.plainstudio.stackcasino.ui.components.EmptyState import com.plainstudio.stackcasino.ui.components.StackCard import com.plainstudio.stackcasino.ui.iconRes import com.plainstudio.stackcasino.ui.theme.AccentViolet @@ -52,17 +54,30 @@ import com.plainstudio.stackcasino.ui.theme.TextMedium /** * Round detail screen reproducing the cu-11 mockup - * (mockup/js/screens/round-detail.js). A static, full-screen view - * reached from the history list: result banner, stats grid, the crash - * position bar, the round timeline, the collapsible provably-fair block - * and the audit-log reference. + * (mockup/js/screens/round-detail.js). A full-screen view reached from + * the history list: result banner, stats grid, the crash position bar, + * the round timeline, the collapsible provably-fair block and the + * audit-log reference. * - * No ViewModel: the screen renders [RoundDetailData] directly. The live - * navigation entry seeds it with [roundDetailPreviewData] until a rounds - * repository can resolve a round by id. + * Stateless: [RoundDetailViewModel] resolves the round id against the + * Room ledger and hands over [RoundDetailUiState] - [Success] with the + * mapped data, [Loading] while Room resolves, or [NotFound]. */ @Composable fun RoundDetailScreen( + state: RoundDetailUiState, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + when (state) { + is RoundDetailUiState.Success -> RoundDetailContent(data = state.data, onBack = onBack, modifier = modifier) + RoundDetailUiState.Loading -> RoundDetailPlaceholder(onBack = onBack, modifier = modifier) + RoundDetailUiState.NotFound -> RoundDetailNotFound(onBack = onBack, modifier = modifier) + } +} + +@Composable +private fun RoundDetailContent( data: RoundDetailData, onBack: () -> Unit, modifier: Modifier = Modifier, @@ -96,6 +111,49 @@ fun RoundDetailScreen( } } +@Composable +private fun RoundDetailPlaceholder( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) { + Column(modifier = Modifier.fillMaxSize()) { + RoundDetailHeader(subtitle = "Loading round...", onBack = onBack) + HairlineDivider() + } + } +} + +@Composable +private fun RoundDetailNotFound( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface(modifier = modifier.fillMaxSize(), color = SurfaceBase) { + Column(modifier = Modifier.fillMaxSize()) { + RoundDetailHeader(subtitle = "Round not found", onBack = onBack) + HairlineDivider() + Box( + modifier = Modifier.fillMaxSize().padding(CardPadding), + contentAlignment = Alignment.Center, + ) { + EmptyState( + icon = { + Icon( + imageVector = Icons.Outlined.SearchOff, + contentDescription = null, + tint = TextLow, + modifier = Modifier.size(NotFoundIconSize), + ) + }, + title = "Round not found", + message = "This round is no longer available.", + ) + } + } + } +} + @Composable private fun RoundDetailHeader( subtitle: String, @@ -403,12 +461,13 @@ private val BarHeight = 8.dp private val MarkerTickHeight = 8.dp private val MarkerFontSize = 8.sp private val AuditIconSize = 10.dp +private val NotFoundIconSize = 24.dp private const val CASHOUT_FILL_ALPHA = 0.40f @Preview(showBackground = true, backgroundColor = 0xFF0B0B12, heightDp = 1200) @Composable private fun RoundDetailScreenPreview() { StackcasinoTheme { - RoundDetailScreen(data = roundDetailPreviewData(), onBack = {}) + RoundDetailScreen(state = RoundDetailUiState.Success(roundDetailPreviewData()), onBack = {}) } } diff --git a/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailViewModel.kt b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailViewModel.kt new file mode 100644 index 0000000..809a28b --- /dev/null +++ b/app/src/main/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailViewModel.kt @@ -0,0 +1,140 @@ +package com.plainstudio.stackcasino.feature.rounddetail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.plainstudio.stackcasino.domain.game.CoinSide +import com.plainstudio.stackcasino.domain.game.GameRepository +import com.plainstudio.stackcasino.domain.game.GameRound +import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.model.RoundOutcome +import com.plainstudio.stackcasino.navigation.Route +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject + +/** + * Resolves the round id passed through the nav arg against the Room + * round ledger and maps the settled [GameRound] into the [RoundDetailData] + * the screen renders. The provably-fair block carries the real seeds / + * nonce / hash so the outcome can be recomputed and verified (cu-11). + * + * Only coinflip rounds are persisted today; the mapping branches on + * [GameRound.game] so the other games slot in as they land. + */ +@HiltViewModel +class RoundDetailViewModel + @Inject + constructor( + savedStateHandle: SavedStateHandle, + private val gameRepository: GameRepository, + ) : ViewModel() { + private val roundId: String? = savedStateHandle[Route.RoundDetail.ARG_ROUND_ID] + + private val _uiState = MutableStateFlow(RoundDetailUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + val round = roundId?.let { gameRepository.getRound(it) } + _uiState.value = + if (round != null) { + RoundDetailUiState.Success(round.toDetailData()) + } else { + RoundDetailUiState.NotFound + } + } + } + + private fun GameRound.toDetailData(): RoundDetailData { + val landed = CoinSide.fromEngineResult(resultRaw) + val won = outcome == RoundOutcome.Win + val resultTone = if (won) StatTone.Accent else StatTone.Danger + val short = shortId() + return RoundDetailData( + game = game, + gameName = game.displayName(), + headerSubtitle = "${game.displayName()} · #$short · $currency", + outcome = outcome, + netProfitLabel = signedMoney(netProfit), + summaryLabel = "Bet $${money(stake)} · Payout $${money(payout)}", + stats = + listOf( + RoundStat(label = "Bet", value = "$${money(stake)}", tone = StatTone.Neutral), + RoundStat(label = "Prediction", value = pick, tone = StatTone.Accent), + RoundStat(label = "Result", value = landed.name, tone = resultTone), + ), + // The crash bar is crash-specific; coinflip has no cashout-vs-crash viz. + crashBar = null, + safeByLabel = null, + timeline = buildTimeline(won, landed), + provablyFair = + ProvablyFair( + serverSeed = SeedValue("Server Seed", serverSeed), + clientSeed = SeedValue("Client Seed", clientSeed), + nonce = SeedValue("Nonce", nonce.toString()), + hmac = SeedValue("HMAC-SHA256 Hash", hash), + derivation = "Result derived from HMAC-SHA256(server_seed, client_seed:nonce)", + ), + auditRef = "AUD-${short.uppercase(Locale.US)}", + ) + } + + private fun GameRound.buildTimeline( + won: Boolean, + landed: CoinSide, + ): List { + val at = TIME_FORMAT.format(Date(timestamp)) + return listOf( + TimelineEvent("Round started", at, TimelineTone.Neutral), + TimelineEvent("Bet placed · $${money(stake)}", at, TimelineTone.Neutral), + TimelineEvent("Landed ${landed.name}", at, if (won) TimelineTone.Ok else TimelineTone.Danger), + if (won) { + TimelineEvent("Payout credited · $${money(payout)}", at, TimelineTone.Ok) + } else { + TimelineEvent("No payout · stake lost", at, TimelineTone.Danger) + }, + ) + } + + private fun GameRound.shortId(): String = id.filter { it.isLetterOrDigit() }.take(SHORT_ID_LEN) + + private fun GameKey.displayName(): String = + when (this) { + GameKey.Roulette -> "Roulette" + GameKey.Blackjack -> "Blackjack" + GameKey.Crash -> "Crash" + GameKey.Mines -> "Mines" + GameKey.Coinflip -> "Coinflip" + } + + private fun money(value: Double): String = String.format(Locale.US, "%,.2f", value) + + private fun signedMoney(value: Double): String = (if (value < 0) "-$" else "+$") + money(kotlin.math.abs(value)) + + private companion object { + const val SHORT_ID_LEN = 6 + val TIME_FORMAT = SimpleDateFormat("h:mm:ss a", Locale.US) + } + } + +/** + * Lifecycle of the round detail screen: [Loading] until Room resolves the + * id, [Success] with the mapped data, or [NotFound] when the id is + * unknown (e.g. an external deep link). + */ +sealed interface RoundDetailUiState { + data object Loading : RoundDetailUiState + + data class Success( + val data: RoundDetailData, + ) : RoundDetailUiState + + data object NotFound : RoundDetailUiState +} diff --git a/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt b/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt index 099f34e..2204879 100644 --- a/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt +++ b/app/src/main/java/com/plainstudio/stackcasino/navigation/StackNavHost.kt @@ -22,7 +22,7 @@ import com.plainstudio.stackcasino.feature.coinflip.CoinflipScreen import com.plainstudio.stackcasino.feature.coinflip.CoinflipViewModel import com.plainstudio.stackcasino.feature.crash.CrashScreen import com.plainstudio.stackcasino.feature.history.HistoryScreen -import com.plainstudio.stackcasino.feature.history.historyPreviewData +import com.plainstudio.stackcasino.feature.history.HistoryViewModel import com.plainstudio.stackcasino.feature.housewallet.HouseWalletScreen import com.plainstudio.stackcasino.feature.kyc.KycScreen import com.plainstudio.stackcasino.feature.lobby.LobbyScreen @@ -37,7 +37,7 @@ import com.plainstudio.stackcasino.feature.profile.ProfileScreen import com.plainstudio.stackcasino.feature.profile.SettingsViewModel import com.plainstudio.stackcasino.feature.roulette.RouletteScreen import com.plainstudio.stackcasino.feature.rounddetail.RoundDetailScreen -import com.plainstudio.stackcasino.feature.rounddetail.roundDetailPreviewData +import com.plainstudio.stackcasino.feature.rounddetail.RoundDetailViewModel import com.plainstudio.stackcasino.feature.wallet.WalletScreen import com.plainstudio.stackcasino.feature.wallet.WalletTab import com.plainstudio.stackcasino.feature.wallet.WalletViewModel @@ -79,8 +79,10 @@ fun StackNavHost( addLobbyRoute(navController) addWalletRoute(navController) composable(Route.History.path) { + val viewModel: HistoryViewModel = hiltViewModel() + val data by viewModel.historyData.collectAsStateWithLifecycle() HistoryScreen( - data = historyPreviewData(), + data = data, onOpenRound = { roundId -> navController.navigate(Route.RoundDetail.build(roundId)) { launchSingleTop = true @@ -261,8 +263,10 @@ private fun NavGraphBuilder.addParametricRoutes(navController: NavHostController route = Route.RoundDetail.path, arguments = listOf(navArgument(Route.RoundDetail.ARG_ROUND_ID) { type = NavType.StringType }), ) { + val viewModel: RoundDetailViewModel = hiltViewModel() + val state by viewModel.uiState.collectAsStateWithLifecycle() RoundDetailScreen( - data = roundDetailPreviewData(), + state = state, onBack = { navController.popBackStack() }, ) } diff --git a/app/src/test/java/com/plainstudio/stackcasino/data/game/GameRepositoryImplTest.kt b/app/src/test/java/com/plainstudio/stackcasino/data/game/GameRepositoryImplTest.kt index bb664ca..01155e1 100644 --- a/app/src/test/java/com/plainstudio/stackcasino/data/game/GameRepositoryImplTest.kt +++ b/app/src/test/java/com/plainstudio/stackcasino/data/game/GameRepositoryImplTest.kt @@ -1,6 +1,7 @@ package com.plainstudio.stackcasino.data.game import com.plainstudio.stackcasino.data.local.GameRoundDao +import com.plainstudio.stackcasino.data.local.GameRoundEntity import com.plainstudio.stackcasino.domain.game.CoinSide import com.plainstudio.stackcasino.domain.game.EngineResult import com.plainstudio.stackcasino.domain.game.GameEngine @@ -129,6 +130,42 @@ class GameRepositoryImplTest { coVerify(exactly = 0) { dao.insertRound(any()) } } + @Test + fun `getRound maps the stored entity into a domain round`() = + runTest { + coEvery { dao.getRound("r1") } returns + GameRoundEntity( + id = "r1", + game = "Coinflip", + pick = "Heads", + stake = 10.0, + payout = 20.0, + won = true, + multiplier = 2.0, + currency = "USDC", + serverSeed = "server", + clientSeed = "client", + nonce = 3L, + hash = "hash", + resultRaw = "0", + timestamp = 0L, + ) + + val round = repository().getRound("r1") + + assertEquals("r1", round?.id) + assertEquals(RoundOutcome.Win, round?.outcome) + assertEquals("server", round?.serverSeed) + } + + @Test + fun `getRound returns null for an unknown id`() = + runTest { + coEvery { dao.getRound(any()) } returns null + + assertEquals(null, repository().getRound("missing")) + } + private fun wallet(available: Double) = Wallet( availableBalance = available, diff --git a/app/src/test/java/com/plainstudio/stackcasino/feature/history/HistoryViewModelTest.kt b/app/src/test/java/com/plainstudio/stackcasino/feature/history/HistoryViewModelTest.kt new file mode 100644 index 0000000..2386aa9 --- /dev/null +++ b/app/src/test/java/com/plainstudio/stackcasino/feature/history/HistoryViewModelTest.kt @@ -0,0 +1,86 @@ +package com.plainstudio.stackcasino.feature.history + +import app.cash.turbine.test +import com.plainstudio.stackcasino.domain.game.GameRepository +import com.plainstudio.stackcasino.domain.game.GameRound +import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.model.RoundOutcome +import com.plainstudio.stackcasino.testing.MainDispatcherRule +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class HistoryViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val gameRepository = mockk(relaxed = true) + + @Test + fun `maps rounds into rows and aggregates the summary`() = + runTest { + every { gameRepository.rounds } returns + flowOf( + listOf( + round(won = true, stake = 10.0, payout = 20.0, pick = "Heads", resultRaw = "0"), + round(won = false, stake = 50.0, payout = 0.0, pick = "Tails", resultRaw = "0"), + ), + ) + + HistoryViewModel(gameRepository).historyData.test { + var data = awaitItem() + while (data.rounds.size < 2) data = awaitItem() + + assertEquals(2, data.summary.totalRounds) + assertEquals(50, data.summary.winRatePercent) + // net = (20 - 10) + (0 - 50) = -40 + assertEquals("-$40.00", data.summary.netLabel) + // Coinflip rows surface the prediction in the third metric. + assertEquals("Heads", data.rounds[0].multiplierLabel) + assertEquals("$10.00", data.rounds[0].betLabel) + assertEquals(RoundOutcome.Win, data.rounds[0].outcome) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `an empty ledger yields a zeroed summary`() = + runTest { + every { gameRepository.rounds } returns flowOf(emptyList()) + + HistoryViewModel(gameRepository).historyData.test { + val data = awaitItem() + assertEquals(0, data.summary.totalRounds) + assertEquals(0, data.summary.winRatePercent) + assertEquals("+$0.00", data.summary.netLabel) + cancelAndIgnoreRemainingEvents() + } + } + + private fun round( + won: Boolean, + stake: Double, + payout: Double, + pick: String, + resultRaw: String, + ) = GameRound( + id = "round-id", + game = GameKey.Coinflip, + pick = pick, + stake = stake, + payout = payout, + outcome = if (won) RoundOutcome.Win else RoundOutcome.Loss, + multiplier = if (won) 2.0 else 0.0, + currency = "USDC", + serverSeed = "s", + clientSeed = "c", + nonce = 1L, + hash = "h", + resultRaw = resultRaw, + timestamp = 0L, + ) +} diff --git a/app/src/test/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailViewModelTest.kt b/app/src/test/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailViewModelTest.kt new file mode 100644 index 0000000..6856c90 --- /dev/null +++ b/app/src/test/java/com/plainstudio/stackcasino/feature/rounddetail/RoundDetailViewModelTest.kt @@ -0,0 +1,81 @@ +package com.plainstudio.stackcasino.feature.rounddetail + +import androidx.lifecycle.SavedStateHandle +import com.plainstudio.stackcasino.domain.game.GameRepository +import com.plainstudio.stackcasino.domain.game.GameRound +import com.plainstudio.stackcasino.model.GameKey +import com.plainstudio.stackcasino.model.RoundOutcome +import com.plainstudio.stackcasino.navigation.Route +import com.plainstudio.stackcasino.testing.MainDispatcherRule +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class RoundDetailViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val gameRepository = mockk() + + private fun savedStateFor(id: String?) = SavedStateHandle(mapOf(Route.RoundDetail.ARG_ROUND_ID to id)) + + @Test + fun `maps a found round into Success with the real provably-fair fields`() = + runTest { + coEvery { gameRepository.getRound("r1") } returns wonRound() + + val state = RoundDetailViewModel(savedStateFor("r1"), gameRepository).uiState.value + + assertTrue(state is RoundDetailUiState.Success) + val data = (state as RoundDetailUiState.Success).data + assertEquals(GameKey.Coinflip, data.game) + assertEquals(RoundOutcome.Win, data.outcome) + assertEquals("+$10.00", data.netProfitLabel) + assertEquals("server-seed", data.provablyFair.serverSeed.value) + assertEquals("client-seed", data.provablyFair.clientSeed.value) + assertEquals("7", data.provablyFair.nonce.value) + assertEquals("the-hash", data.provablyFair.hmac.value) + // Coinflip has no crash-position viz. + assertEquals(null, data.crashBar) + } + + @Test + fun `an unknown id yields NotFound`() = + runTest { + coEvery { gameRepository.getRound(any()) } returns null + + val state = RoundDetailViewModel(savedStateFor("missing"), gameRepository).uiState.value + + assertEquals(RoundDetailUiState.NotFound, state) + } + + @Test + fun `a missing nav arg yields NotFound`() = + runTest { + val state = RoundDetailViewModel(savedStateFor(null), gameRepository).uiState.value + + assertEquals(RoundDetailUiState.NotFound, state) + } + + private fun wonRound() = + GameRound( + id = "r1abc99", + game = GameKey.Coinflip, + pick = "Heads", + stake = 10.0, + payout = 20.0, + outcome = RoundOutcome.Win, + multiplier = 2.0, + currency = "USDC", + serverSeed = "server-seed", + clientSeed = "client-seed", + nonce = 7L, + hash = "the-hash", + resultRaw = "0", + timestamp = 0L, + ) +}