Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Round detail screen (cu-11): a static, full-screen view reached from
the history list with the result banner, the three-up stats grid, the
crash position bar, the round timeline, a collapsible provably-fair
block whose seeds copy to the clipboard, and the audit-log reference.
Replaces the round detail placeholder in the nav graph.
- Profile screen (cu-15): `ProfileViewModel` maps the signed-in
`AuthRepository.currentUser` into the identity block (name, email,
Google provider, avatar monogram); the P&L summary and game-activity
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.plainstudio.stackcasino.feature.rounddetail

import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.plainstudio.stackcasino.ui.theme.StackcasinoTheme
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class RoundDetailScreenTest {
@get:Rule
val composeRule = createComposeRule()

private fun setScreen(onBack: () -> Unit = {}) {
composeRule.setContent {
StackcasinoTheme {
RoundDetailScreen(data = roundDetailPreviewData(), onBack = onBack)
}
}
}

@Test
fun renders_the_result_banner_stats_and_timeline() {
setScreen()

composeRule.onNodeWithText("Round Detail").assertIsDisplayed()
composeRule.onNodeWithText("+$75.50").assertIsDisplayed()
composeRule.onNodeWithText("Crash Point").performScrollTo().assertIsDisplayed()
composeRule.onNodeWithText("Timeline").performScrollTo().assertIsDisplayed()
}

@Test
fun provably_fair_is_collapsed_until_tapped() {
setScreen()

composeRule.onAllNodesWithText("Server Seed").assertCountEquals(0)

composeRule.onNodeWithText("Provably Fair").performScrollTo().performClick()
composeRule.waitForIdle()

composeRule.onNodeWithText("Server Seed").performScrollTo().assertIsDisplayed()
composeRule.onNodeWithText("Nonce").performScrollTo().assertIsDisplayed()
}

@Test
fun back_button_invokes_the_callback() {
var backPressed = false
setScreen(onBack = { backPressed = true })

composeRule.onNodeWithContentDescription("Back").performClick()

assertTrue(backPressed)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package com.plainstudio.stackcasino.feature.rounddetail

import com.plainstudio.stackcasino.model.GameKey
import com.plainstudio.stackcasino.model.RoundOutcome

/**
* Everything the round detail screen renders. The screen is a static
* shell for now: [roundDetailPreviewData] mirrors the cu-11 mockup
* (mockup/js/screens/round-detail.js) and the live navigation entry
* uses it until a rounds repository can resolve a round by id.
*
* [crashBar] and [safeByLabel] are crash-specific and null for games
* that have no cashout-vs-crash visualization.
*/
data class RoundDetailData(
val game: GameKey,
val gameName: String,
val headerSubtitle: String,
val outcome: RoundOutcome,
val netProfitLabel: String,
val summaryLabel: String,
val stats: List<RoundStat>,
val crashBar: CrashBar?,
val safeByLabel: String?,
val timeline: List<TimelineEvent>,
val provablyFair: ProvablyFair,
val auditRef: String,
)

/** One cell in the three-up stats grid. */
data class RoundStat(
val label: String,
val value: String,
val tone: StatTone,
)

enum class StatTone { Neutral, Accent, Danger }

/**
* Crash position bar: [cashoutFraction] is where the player cashed out
* relative to the crash point (which sits at the full width).
*/
data class CrashBar(
val cashoutFraction: Float,
val cashoutLabel: String,
val crashLabel: String,
)

/** One entry in the vertical round timeline. */
data class TimelineEvent(
val label: String,
val timeLabel: String,
val tone: TimelineTone,
)

enum class TimelineTone { Neutral, Ok, Danger }

/**
* Provably-fair inputs for the round. Each [SeedValue] carries the full
* value (copied to the clipboard) plus its display label; the composable
* truncates the value for display via [truncateMiddle].
*/
data class ProvablyFair(
val serverSeed: SeedValue,
val clientSeed: SeedValue,
val nonce: SeedValue,
val hmac: SeedValue,
val derivation: String,
)

data class SeedValue(
val label: String,
val value: String,
)

/**
* Shortens a long opaque value to `head...tail` with an ellipsis in the
* middle, leaving short values untouched. Used for the provably-fair
* seeds and hash so the full value stays one clipboard tap away while
* the row keeps a single line.
*/
internal fun truncateMiddle(
value: String,
keepHead: Int = SEED_KEEP_HEAD,
keepTail: Int = SEED_KEEP_TAIL,
): String =
if (value.length <= keepHead + keepTail + 1) {
value
} else {
value.take(keepHead) + "…" + value.takeLast(keepTail)
}

private const val SEED_KEEP_HEAD = 8
private const val SEED_KEEP_TAIL = 8

internal fun roundDetailPreviewData(): RoundDetailData =
RoundDetailData(
game = GameKey.Crash,
gameName = "Crash",
headerSubtitle = "Crash · #48291 · 19s · USDC",
outcome = RoundOutcome.Win,
netProfitLabel = "+$75.50",
summaryLabel = "Bet $50.00 · Payout $125.50",
stats =
listOf(
RoundStat(label = "Bet", value = "$50.00", tone = StatTone.Neutral),
RoundStat(label = "Cashed Out", value = "2.51x", tone = StatTone.Accent),
RoundStat(label = "Crash Point", value = "3.18x", tone = StatTone.Danger),
),
crashBar =
CrashBar(
cashoutFraction = 0.789f,
cashoutLabel = "2.51x",
crashLabel = "3.18x",
),
safeByLabel = "Safe by 2.1s",
timeline =
listOf(
TimelineEvent("Round started", "7:30:00 AM", TimelineTone.Neutral),
TimelineEvent("Bet placed · $50.00", "7:30:01 AM", TimelineTone.Neutral),
TimelineEvent("Cash out at 2.51x", "7:30:14 AM", TimelineTone.Ok),
TimelineEvent("Crashed at 3.18x", "7:30:19 AM", TimelineTone.Danger),
TimelineEvent("Payout credited · $125.50", "7:30:19 AM", TimelineTone.Ok),
),
provablyFair =
ProvablyFair(
serverSeed = SeedValue("Server Seed", "a3f8c2e1b4d79f6a0c3e8b12"),
clientSeed = SeedValue("Client Seed", "7e2d4f8a1c3b5d9e0f2a6c84"),
nonce = SeedValue("Nonce", "48291"),
hmac = SeedValue("HMAC-SHA256 Hash", "e4a7f2c8d1b36e9a0f5c2d84a7b1e3f8c6d0a9b2e5f4"),
derivation = "Result derived from HMAC-SHA256(server_seed, client_seed:nonce)",
),
auditRef = "AUD-98412",
)
Loading
Loading