Signed-off-by: Matt Bruce <matt.bruce1@toyota.com>

This commit is contained in:
Matt Bruce 2025-12-28 22:27:44 -06:00
parent 55fd30d77e
commit 24a9cfb5b7
11 changed files with 867 additions and 31 deletions

View File

@ -105,11 +105,29 @@ final class GameState {
}
var playerHandValue: Int {
engine.playerHand.value
// Only calculate value from visible face-up cards
guard visiblePlayerCards.count == playerCardsFaceUp.count else { return 0 }
guard playerCardsFaceUp.allSatisfy({ $0 }) else {
// If not all cards are face up, calculate value from only the face-up cards
let faceUpCards = zip(visiblePlayerCards, playerCardsFaceUp)
.filter { $1 }
.map { $0.0 }
return faceUpCards.reduce(0) { $0 + $1.baccaratValue } % 10
}
return engine.playerHand.value
}
var bankerHandValue: Int {
engine.bankerHand.value
// Only calculate value from visible face-up cards
guard visibleBankerCards.count == bankerCardsFaceUp.count else { return 0 }
guard bankerCardsFaceUp.allSatisfy({ $0 }) else {
// If not all cards are face up, calculate value from only the face-up cards
let faceUpCards = zip(visibleBankerCards, bankerCardsFaceUp)
.filter { $1 }
.map { $0.0 }
return faceUpCards.reduce(0) { $0 + $1.baccaratValue } % 10
}
return engine.bankerHand.value
}
// Recent results for the road display (last 20)
@ -684,9 +702,11 @@ final class GameState {
sound.playCardDeal()
visiblePlayerCards.append(playerThird)
playerCardsFaceUp.append(false)
CasinoDesign.debugLog("🃏 Player 3rd card dealt face-down: cards=\(visiblePlayerCards.count), faceUp=\(playerCardsFaceUp)")
try? await Task.sleep(for: shortDelay)
sound.playCardFlip()
playerCardsFaceUp[2] = true
CasinoDesign.debugLog("🃏 Player 3rd card flipped: cards=\(visiblePlayerCards.count), faceUp=\(playerCardsFaceUp)")
try? await Task.sleep(for: flipDelay)
} else {
visiblePlayerCards.append(playerThird)
@ -702,9 +722,11 @@ final class GameState {
sound.playCardDeal()
visibleBankerCards.append(bankerThird)
bankerCardsFaceUp.append(false)
CasinoDesign.debugLog("🃏 Banker 3rd card dealt face-down: cards=\(visibleBankerCards.count), faceUp=\(bankerCardsFaceUp)")
try? await Task.sleep(for: shortDelay)
sound.playCardFlip()
bankerCardsFaceUp[2] = true
CasinoDesign.debugLog("🃏 Banker 3rd card flipped: cards=\(visibleBankerCards.count), faceUp=\(bankerCardsFaceUp)")
try? await Task.sleep(for: dealDelay)
} else {
visibleBankerCards.append(bankerThird)

View File

@ -166,6 +166,10 @@ struct GameTableView: View {
onStartPlaying: {
showWelcome = false
state.onboarding.completeWelcome()
// Mark all hints as shown so they don't appear
state.onboarding.markHintShown("bettingZone")
state.onboarding.markHintShown("dealButton")
state.onboarding.markHintShown("firstResult")
}
)
}

View File

@ -206,14 +206,28 @@ struct CardsDisplayArea: View {
// MARK: - Private Views
private func playerHandSection(width: CGFloat) -> some View {
VStack(spacing: Design.Spacing.small) {
// Calculate value from face-up cards
let visibleValue: Int = {
guard !playerCards.isEmpty else { return 0 }
let faceUpCards = zip(playerCards, playerCardsFaceUp)
.filter { $1 }
.map { $0.0 }
return faceUpCards.reduce(0) { $0 + $1.baccaratValue } % 10
}()
let allCardsFaceUp = !playerCards.isEmpty && playerCardsFaceUp.count == playerCards.count && playerCardsFaceUp.allSatisfy({ $0 })
let _ = CasinoDesign.debugLog("🎯 Player: cards=\(playerCards.count), faceUp=\(playerCardsFaceUp.count), allFaceUp=\(allCardsFaceUp), visibleValue=\(visibleValue)")
return VStack(spacing: Design.Spacing.small) {
HStack(spacing: Design.Spacing.small) {
Text("PLAYER")
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
.foregroundStyle(.white)
if !playerCards.isEmpty && playerCardsFaceUp.contains(true) {
HandValueBadge(value: playerValue, color: .blue)
// Always show value when there are cards - it updates as cards become visible
if !playerCards.isEmpty {
HandValueBadge(value: visibleValue, color: .blue)
.animation(nil, value: visibleValue) // No animation when value changes
}
}
.frame(minHeight: labelRowMinHeight)
@ -234,14 +248,28 @@ struct CardsDisplayArea: View {
}
private func bankerHandSection(width: CGFloat) -> some View {
VStack(spacing: Design.Spacing.small) {
// Calculate value from face-up cards
let visibleValue: Int = {
guard !bankerCards.isEmpty else { return 0 }
let faceUpCards = zip(bankerCards, bankerCardsFaceUp)
.filter { $1 }
.map { $0.0 }
return faceUpCards.reduce(0) { $0 + $1.baccaratValue } % 10
}()
let allCardsFaceUp = !bankerCards.isEmpty && bankerCardsFaceUp.count == bankerCards.count && bankerCardsFaceUp.allSatisfy({ $0 })
let _ = CasinoDesign.debugLog("🎯 Banker: cards=\(bankerCards.count), faceUp=\(bankerCardsFaceUp.count), allFaceUp=\(allCardsFaceUp), visibleValue=\(visibleValue)")
return VStack(spacing: Design.Spacing.small) {
HStack(spacing: Design.Spacing.small) {
Text("BANKER")
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
.foregroundStyle(.white)
if !bankerCards.isEmpty && bankerCardsFaceUp.contains(true) {
HandValueBadge(value: bankerValue, color: .red)
// Always show value when there are cards - it updates as cards become visible
if !bankerCards.isEmpty {
HandValueBadge(value: visibleValue, color: .red)
.animation(nil, value: visibleValue) // No animation when value changes
}
}
.frame(minHeight: labelRowMinHeight)

View File

@ -9,28 +9,245 @@ import XCTest
final class BaccaratUITests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
app = XCUIApplication()
app.launchArguments = ["UI-TESTING"]
// In UI tests it's important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
app = nil
}
@MainActor
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
// MARK: - UI Navigation Tests
@MainActor
func testAppLaunches() throws {
app.launch()
XCTAssertTrue(app.exists, "App should launch successfully")
}
@MainActor
func testTopBarElements() throws {
app.launch()
// Wait for the app to load
let balanceText = app.staticTexts.matching(identifier: "balance").firstMatch
XCTAssertTrue(balanceText.waitForExistence(timeout: 2), "Balance should be visible")
// Check settings button exists
let settingsButton = app.buttons.matching(identifier: "settings").firstMatch
XCTAssertTrue(settingsButton.exists, "Settings button should exist")
// Check help button exists
let helpButton = app.buttons.matching(identifier: "help").firstMatch
XCTAssertTrue(helpButton.exists, "Help button should exist")
// Check stats button exists
let statsButton = app.buttons.matching(identifier: "stats").firstMatch
XCTAssertTrue(statsButton.exists, "Stats button should exist")
}
@MainActor
func testSettingsSheet() throws {
app.launch()
// Find and tap settings button
let settingsButton = app.buttons.matching(identifier: "settings").firstMatch
XCTAssertTrue(settingsButton.waitForExistence(timeout: 2), "Settings button should exist")
settingsButton.tap()
// Wait for settings sheet to appear
let settingsSheet = app.scrollViews.firstMatch
XCTAssertTrue(settingsSheet.waitForExistence(timeout: 2), "Settings sheet should appear")
// Check for some expected elements in settings
XCTAssertTrue(app.staticTexts["Table Limits"].waitForExistence(timeout: 1) ||
app.staticTexts["Game Settings"].waitForExistence(timeout: 1),
"Settings content should be visible")
// Close settings (tap outside or dismiss button)
if app.buttons["Done"].exists {
app.buttons["Done"].tap()
} else {
// Tap outside to dismiss
app.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.1)).tap()
}
}
@MainActor
func testHelpSheet() throws {
app.launch()
// Find and tap help button
let helpButton = app.buttons.matching(identifier: "help").firstMatch
XCTAssertTrue(helpButton.waitForExistence(timeout: 2), "Help button should exist")
helpButton.tap()
// Wait for help sheet to appear
let helpSheet = app.scrollViews.firstMatch
XCTAssertTrue(helpSheet.waitForExistence(timeout: 2), "Help sheet should appear")
// Check for rules content
XCTAssertTrue(app.staticTexts["Rules"].exists ||
app.staticTexts["How to Play"].exists,
"Rules content should be visible")
}
// MARK: - Betting Tests
@MainActor
func testChipSelector() throws {
app.launch()
// Wait for chip selector to appear
let chipSelector = app.otherElements["chip-selector"]
XCTAssertTrue(chipSelector.waitForExistence(timeout: 2) ||
app.buttons.matching(NSPredicate(format: "label CONTAINS '$'")).count > 0,
"Chip selector should be visible")
// Find a chip button (they should have $ in the label)
let chipButtons = app.buttons.matching(NSPredicate(format: "label CONTAINS '$'"))
XCTAssertGreaterThan(chipButtons.count, 0, "Should have chip buttons")
}
@MainActor
func testBettingZones() throws {
app.launch()
// Wait for betting table
sleep(1)
// Check for Player, Banker, and Tie betting zones
let playerZone = app.staticTexts["PLAYER"]
let bankerZone = app.staticTexts["BANKER"]
let tieZone = app.staticTexts["TIE"]
XCTAssertTrue(playerZone.exists, "Player betting zone should exist")
XCTAssertTrue(bankerZone.exists, "Banker betting zone should exist")
XCTAssertTrue(tieZone.exists, "Tie betting zone should exist")
}
@MainActor
func testPlaceBet() throws {
app.launch()
// Wait for betting area
sleep(1)
// Find deal button (should be disabled initially)
let dealButton = app.buttons["Deal"]
XCTAssertTrue(dealButton.waitForExistence(timeout: 2), "Deal button should exist")
// Initially deal button should be disabled
XCTAssertFalse(dealButton.isEnabled, "Deal button should be disabled before bet")
// Tap on Player betting zone
let playerZone = app.staticTexts["PLAYER"]
if playerZone.exists {
// Tap multiple times to ensure we meet minimum bet
for _ in 1...5 {
playerZone.tap()
}
// Deal button should now be enabled after placing sufficient bet
sleep(1)
// Note: Button might still be disabled if bet is below minimum
}
}
@MainActor
func testClearButton() throws {
app.launch()
// Wait and place a bet
sleep(1)
let playerZone = app.staticTexts["PLAYER"]
if playerZone.exists {
playerZone.tap()
}
// Find clear button
let clearButton = app.buttons["Clear"]
if clearButton.exists {
clearButton.tap()
// After clearing, deal button should be disabled again
let dealButton = app.buttons["Deal"]
XCTAssertFalse(dealButton.isEnabled, "Deal button should be disabled after clear")
}
}
// MARK: - Gameplay Tests
@MainActor
func testDealCards() throws {
app.launch()
sleep(1)
// Place a bet on Player by tapping multiple times
let playerZone = app.staticTexts["PLAYER"]
if playerZone.exists {
for _ in 1...5 {
playerZone.tap()
}
}
// Find and tap deal button
let dealButton = app.buttons["Deal"]
if dealButton.waitForExistence(timeout: 2) && dealButton.isEnabled {
dealButton.tap()
// Wait for cards to be dealt
sleep(3)
// After dealing, we should see hand value badges or a result
// Check for the PLAYER and BANKER labels which should still be visible
let playerLabel = app.staticTexts["PLAYER"]
let bankerLabel = app.staticTexts["BANKER"]
XCTAssertTrue(playerLabel.exists, "Player label should be visible after deal")
XCTAssertTrue(bankerLabel.exists, "Banker label should be visible after deal")
// Eventually a New Round button should appear
let newRoundButton = app.buttons["New Round"]
XCTAssertTrue(newRoundButton.waitForExistence(timeout: 5), "New Round button should appear")
}
}
@MainActor
func testRoadMapExists() throws {
app.launch()
// Road map might not be visible on small screens or in portrait mode
// This is an optional test that checks if enabled in settings
sleep(1)
// Look for history text or road map elements
let historyText = app.staticTexts["HISTORY"]
// Road map is conditional, so this is not a required assertion
// Just checking it doesn't crash
_ = historyText.exists
}
@MainActor
func testLaunchPerformance() throws {
// This measures how long it takes to launch your application.

View File

@ -74,6 +74,12 @@ final class GameState {
/// Index of the hand currently being played.
private(set) var activeHandIndex: Int = 0
/// Number of cards that have completed their deal animation for each player hand.
private(set) var playerHandsVisibleCardCount: [Int] = []
/// Number of cards that have completed their deal animation for dealer hand.
private(set) var dealerVisibleCardCount: Int = 0
/// Whether an action is currently being processed (prevents double-tap issues).
private(set) var isProcessingAction: Bool = false
@ -487,6 +493,10 @@ final class GameState {
perfectPairsResult = nil
twentyOnePlusThreeResult = nil
// Reset visible card counts for animations
playerHandsVisibleCardCount = [0]
dealerVisibleCardCount = 0
let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0
// European no-hole-card: deal 3 cards (player, dealer, player)
@ -504,6 +514,13 @@ final class GameState {
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
}
// Mark card as visible after animation delay
if i % 2 == 0 {
playerHandsVisibleCardCount[0] += 1
} else {
dealerVisibleCardCount += 1
}
}
}
@ -622,6 +639,15 @@ final class GameState {
playerHands[activeHandIndex].cards.append(card)
sound.play(.cardDeal)
// Wait for animation if enabled
let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
}
// Mark card as visible after animation
playerHandsVisibleCardCount[activeHandIndex] += 1
// Check for bust or 21
if playerHands[activeHandIndex].isBusted {
playerHands[activeHandIndex].result = .bust
@ -669,6 +695,15 @@ final class GameState {
if let card = engine.dealCard() {
playerHands[activeHandIndex].cards.append(card)
sound.play(.cardDeal)
// Wait for animation if enabled
let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
}
// Mark card as visible after animation
playerHandsVisibleCardCount[activeHandIndex] += 1
}
if playerHands[activeHandIndex].isBusted {
@ -707,14 +742,22 @@ final class GameState {
sound.play(.chipPlace)
// Deal one card to each hand
let delay = settings.showAnimations ? 0.3 * settings.dealingSpeed : 0
if let card1 = engine.dealCard() {
hand1.cards.append(card1)
sound.play(.cardDeal)
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
}
}
if let card2 = engine.dealCard() {
hand2.cards.append(card2)
sound.play(.cardDeal)
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
}
}
// Replace original with split hands
@ -722,6 +765,11 @@ final class GameState {
playerHands.insert(hand1, at: activeHandIndex)
playerHands.insert(hand2, at: activeHandIndex + 1)
// Update visible card counts - each split hand starts with 2 visible cards
playerHandsVisibleCardCount.remove(at: activeHandIndex)
playerHandsVisibleCardCount.insert(2, at: activeHandIndex)
playerHandsVisibleCardCount.insert(2, at: activeHandIndex + 1)
// If split aces, typically only one card each and stand
if originalHand.cards[0].rank == .ace && !settings.resplitAces {
playerHands[activeHandIndex].isStanding = true
@ -790,6 +838,9 @@ final class GameState {
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
}
// Mark card as visible after animation
dealerVisibleCardCount += 1
}
// Check for dealer blackjack in European mode
@ -809,12 +860,17 @@ final class GameState {
return
}
} else {
// American style: reveal hole card
// American style: reveal hole card (card is already in hand, just mark as visible)
sound.play(.cardFlip)
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
}
// Mark hole card as visible (if not already)
if dealerVisibleCardCount < dealerHand.cards.count {
dealerVisibleCardCount = dealerHand.cards.count
}
}
// Dealer draws
@ -826,6 +882,9 @@ final class GameState {
if delay > 0 {
try? await Task.sleep(for: .seconds(delay))
}
// Mark card as visible after animation
dealerVisibleCardCount += 1
}
}
@ -1057,6 +1116,10 @@ final class GameState {
dealerHand = BlackjackHand()
activeHandIndex = 0
// Reset visible card counts
playerHandsVisibleCardCount = []
dealerVisibleCardCount = 0
// Reset bets
currentBet = 0
insuranceBet = 0

View File

@ -114,6 +114,10 @@ struct GameTableView: View {
onStartPlaying: {
showWelcome = false
state.onboarding.completeWelcome()
// Mark all hints as shown so they don't appear
state.onboarding.markHintShown("bettingZone")
state.onboarding.markHintShown("dealButton")
state.onboarding.markHintShown("playerActions")
}
)
}

View File

@ -94,7 +94,8 @@ struct BlackjackTableView: View {
showAnimations: state.settings.showAnimations,
dealingSpeed: state.settings.dealingSpeed,
cardWidth: cardWidth,
cardSpacing: cardSpacing
cardSpacing: cardSpacing,
visibleCardCount: state.dealerVisibleCardCount
)
.debugBorder(showDebugBorders, color: .red, label: "Dealer")
@ -127,6 +128,7 @@ struct BlackjackTableView: View {
dealingSpeed: state.settings.dealingSpeed,
cardWidth: cardWidth,
cardSpacing: cardSpacing,
visibleCardCounts: state.playerHandsVisibleCardCount,
currentHint: state.currentHint,
showHintToast: state.showHintToast
)

View File

@ -17,6 +17,9 @@ struct DealerHandView: View {
let cardWidth: CGFloat
let cardSpacing: CGFloat
/// Number of cards that have completed their animation
let visibleCardCount: Int
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.Size.handLabelFontSize
@ScaledMetric(relativeTo: .headline) private var badgeHeight: CGFloat = CasinoDesign.Size.valueBadge
@ -34,21 +37,28 @@ struct DealerHandView: View {
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
.foregroundStyle(.white)
// Badge animates in when cards are dealt
if !hand.cards.isEmpty {
if showHoleCard {
// All cards visible - show total hand value
ValueBadge(value: hand.value, color: Color.Hand.dealer)
.transition(.scale.combined(with: .opacity))
} else {
// Hole card hidden - show only the first (face-up) card's value
// Calculate value from visible cards only
if !hand.cards.isEmpty && visibleCardCount > 0 {
if showHoleCard && visibleCardCount >= hand.cards.count {
// All cards visible - calculate total hand value from visible cards
let visibleCards = Array(hand.cards.prefix(visibleCardCount))
let visibleValue = visibleCards.reduce(0) { $0 + $1.blackjackValue }
let visibleHasSoftAce = visibleCards.contains { $0.rank == .ace } && visibleValue + 10 <= 21
let displayValue = visibleHasSoftAce && visibleValue + 10 <= 21 ? visibleValue + 10 : visibleValue
ValueBadge(value: displayValue, color: Color.Hand.dealer)
.animation(nil, value: displayValue) // No animation when value changes
} else if visibleCardCount >= 1 {
// Hole card hidden or not all cards visible - show only the first (face-up) card's value
ValueBadge(value: hand.cards[0].blackjackValue, color: Color.Hand.dealer)
.transition(.scale.combined(with: .opacity))
.animation(nil, value: hand.cards[0].blackjackValue) // No animation when value changes
}
}
}
.frame(minHeight: badgeHeight) // Reserve consistent height
.animation(.spring(duration: Design.Animation.springDuration), value: hand.cards.isEmpty)
// Remove animations on badge appearance/value changes
.animation(nil, value: visibleCardCount)
.animation(nil, value: showHoleCard)
// Cards with result badge overlay (overlay prevents height change)
HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) {
if hand.cards.isEmpty {
@ -152,7 +162,8 @@ struct DealerHandView: View {
showAnimations: true,
dealingSpeed: 1.0,
cardWidth: 60,
cardSpacing: -20
cardSpacing: -20,
visibleCardCount: 0
)
}
}
@ -170,7 +181,8 @@ struct DealerHandView: View {
showAnimations: true,
dealingSpeed: 1.0,
cardWidth: 60,
cardSpacing: -20
cardSpacing: -20,
visibleCardCount: 2
)
}
}
@ -188,7 +200,8 @@ struct DealerHandView: View {
showAnimations: true,
dealingSpeed: 1.0,
cardWidth: 60,
cardSpacing: -20
cardSpacing: -20,
visibleCardCount: 2
)
}
}

View File

@ -21,6 +21,9 @@ struct PlayerHandsView: View {
let cardWidth: CGFloat
let cardSpacing: CGFloat
/// Number of visible cards for each hand (completed animations)
let visibleCardCounts: [Int]
/// Current hint to display (shown on active hand only).
let currentHint: String?
@ -41,6 +44,7 @@ struct PlayerHandsView: View {
// Play order: Hand 1 played first (rightmost), then Hand 2, etc.
ForEach(Array(hands.enumerated()).reversed(), id: \.element.id) { index, hand in
let isActiveHand = index == activeHandIndex && isPlayerTurn
let visibleCount = index < visibleCardCounts.count ? visibleCardCounts[index] : 0
PlayerHandView(
hand: hand,
isActive: isActiveHand,
@ -51,6 +55,7 @@ struct PlayerHandsView: View {
handNumber: hands.count > 1 ? index + 1 : nil,
cardWidth: cardWidth,
cardSpacing: cardSpacing,
visibleCardCount: visibleCount,
// Only show hint on the active hand
currentHint: isActiveHand ? currentHint : nil,
showHintToast: isActiveHand && showHintToast
@ -105,6 +110,9 @@ struct PlayerHandView: View {
let cardWidth: CGFloat
let cardSpacing: CGFloat
/// Number of cards that have completed their animation
let visibleCardCount: Int
/// Current hint to display on this hand.
let currentHint: String?
@ -206,10 +214,35 @@ struct PlayerHandView: View {
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
// Calculate value from visible (animation-completed) cards
// Always show the value - it updates as cards become visible
if !hand.cards.isEmpty {
Text(hand.valueDisplay)
// Use only the cards that have completed their animation
let visibleCards = Array(hand.cards.prefix(visibleCardCount))
let visibleValue = visibleCards.reduce(0) { $0 + $1.blackjackValue }
let visibleHasSoftAce = visibleCards.contains { $0.rank == .ace } && visibleValue + 10 <= 21
let displayValue = visibleHasSoftAce && visibleValue + 10 <= 21 ? visibleValue + 10 : visibleValue
// Determine color based on visible cards
let isVisibleBlackjack = visibleCards.count == 2 && displayValue == 21
let isVisibleBusted = visibleValue > 21
let isVisible21 = displayValue == 21 && !isVisibleBlackjack
let displayColor: Color = {
if isVisibleBlackjack { return .yellow }
if isVisibleBusted { return .red }
if isVisible21 { return .green }
return .white
}()
// Show value like hand.valueDisplay does
let valueText = visibleHasSoftAce ? "\(visibleValue)/\(displayValue)" : "\(displayValue)"
Text(valueText)
.font(.system(size: labelFontSize, weight: .bold, design: .rounded))
.foregroundStyle(valueColor)
.foregroundStyle(displayColor)
.animation(nil, value: valueText) // No animation when text changes
.animation(nil, value: displayColor) // No animation when color changes
}
if hand.isDoubledDown {
@ -268,6 +301,7 @@ struct PlayerHandView: View {
dealingSpeed: 1.0,
cardWidth: 60,
cardSpacing: -20,
visibleCardCounts: [0],
currentHint: nil,
showHintToast: false
)
@ -289,6 +323,7 @@ struct PlayerHandView: View {
dealingSpeed: 1.0,
cardWidth: 60,
cardSpacing: -20,
visibleCardCounts: [2],
currentHint: "Hit",
showHintToast: true
)
@ -324,6 +359,7 @@ struct PlayerHandView: View {
dealingSpeed: 1.0,
cardWidth: 60,
cardSpacing: -20,
visibleCardCounts: [2, 2, 2, 2],
currentHint: "Stand",
showHintToast: true
)

View File

@ -7,6 +7,7 @@
import Testing
@testable import Blackjack
import CasinoKit
struct BlackjackTests {
@ -14,4 +15,273 @@ struct BlackjackTests {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
}
// MARK: - Hand Value Tests
@Test @MainActor func handValueCalculation() async throws {
var hand = BlackjackHand()
// Test empty hand
#expect(hand.value == 0, "Empty hand should have value 0")
// Test simple values
hand = BlackjackHand(cards: [
Card(suit: .spades, rank: .five),
Card(suit: .hearts, rank: .seven)
])
#expect(hand.value == 12, "5 + 7 should equal 12")
#expect(!hand.isSoft, "Hand without ace should not be soft")
// Test face cards
hand = BlackjackHand(cards: [
Card(suit: .clubs, rank: .king),
Card(suit: .diamonds, rank: .queen)
])
#expect(hand.value == 20, "King (10) + Queen (10) should equal 20")
// Test blackjack
hand = BlackjackHand(cards: [
Card(suit: .spades, rank: .ace),
Card(suit: .hearts, rank: .king)
])
#expect(hand.value == 21, "Ace + King should equal 21")
#expect(hand.isBlackjack, "Ace + King should be blackjack")
#expect(hand.isSoft, "Blackjack hand should be soft")
// Test soft hand (ace as 11)
hand = BlackjackHand(cards: [
Card(suit: .diamonds, rank: .ace),
Card(suit: .clubs, rank: .six)
])
#expect(hand.value == 17, "Ace + 6 should equal soft 17")
#expect(hand.isSoft, "Ace + 6 should be soft")
// Test hard hand (ace as 1)
hand = BlackjackHand(cards: [
Card(suit: .spades, rank: .ace),
Card(suit: .hearts, rank: .five),
Card(suit: .clubs, rank: .nine)
])
#expect(hand.value == 15, "Ace + 5 + 9 = 15 (ace counts as 1)")
#expect(!hand.isSoft, "Hand over 21 if ace=11 should be hard")
// Test bust
hand = BlackjackHand(cards: [
Card(suit: .diamonds, rank: .king),
Card(suit: .clubs, rank: .queen),
Card(suit: .hearts, rank: .five)
])
#expect(hand.value == 25, "10 + 10 + 5 = 25")
#expect(hand.isBusted, "Hand over 21 should be busted")
// Test multiple aces
hand = BlackjackHand(cards: [
Card(suit: .spades, rank: .ace),
Card(suit: .hearts, rank: .ace)
])
#expect(hand.value == 12, "Ace + Ace = 12 (one as 11, one as 1)")
#expect(hand.isSoft, "Two aces should be soft")
// Test three aces
hand = BlackjackHand(cards: [
Card(suit: .spades, rank: .ace),
Card(suit: .hearts, rank: .ace),
Card(suit: .clubs, rank: .ace)
])
#expect(hand.value == 13, "Three aces = 13 (one as 11, two as 1)")
#expect(hand.isSoft, "Three aces should be soft")
}
@Test @MainActor func handBlackjackDetection() async throws {
// Natural blackjack
var hand = BlackjackHand(cards: [
Card(suit: .spades, rank: .ace),
Card(suit: .hearts, rank: .ten)
])
#expect(hand.isBlackjack, "Ace + 10 should be blackjack")
// 21 with three cards is not blackjack
hand = BlackjackHand(cards: [
Card(suit: .spades, rank: .seven),
Card(suit: .hearts, rank: .seven),
Card(suit: .clubs, rank: .seven)
])
#expect(hand.value == 21, "Should have value 21")
#expect(!hand.isBlackjack, "21 with 3 cards is not blackjack")
// Split hand 21 is not blackjack
hand = BlackjackHand(cards: [
Card(suit: .spades, rank: .ace),
Card(suit: .hearts, rank: .king)
], bet: 100)
hand.isSplit = true
#expect(hand.value == 21, "Should have value 21")
#expect(!hand.isBlackjack, "21 after split is not blackjack")
}
@Test @MainActor func handSplitDetection() async throws {
// Splittable pair
var hand = BlackjackHand(cards: [
Card(suit: .spades, rank: .eight),
Card(suit: .hearts, rank: .eight)
])
#expect(hand.canSplit, "Pair of 8s should be splittable")
// Face cards of same rank can split
hand = BlackjackHand(cards: [
Card(suit: .spades, rank: .king),
Card(suit: .hearts, rank: .king)
])
#expect(hand.canSplit, "Pair of Kings should be splittable")
// Face cards of different ranks cannot split
hand = BlackjackHand(cards: [
Card(suit: .spades, rank: .king),
Card(suit: .hearts, rank: .queen)
])
#expect(!hand.canSplit, "King + Queen should not be splittable (different ranks)")
// Not splittable - different ranks
hand = BlackjackHand(cards: [
Card(suit: .spades, rank: .nine),
Card(suit: .hearts, rank: .ten)
])
#expect(!hand.canSplit, "9 + 10 should not be splittable")
// Not splittable - more than 2 cards
hand = BlackjackHand(cards: [
Card(suit: .spades, rank: .five),
Card(suit: .hearts, rank: .five),
Card(suit: .clubs, rank: .ace)
])
#expect(!hand.canSplit, "Hand with 3 cards should not be splittable")
}
@Test @MainActor func handDoubleDownDetection() async throws {
// Can double down with 2 cards
var hand = BlackjackHand(cards: [
Card(suit: .spades, rank: .five),
Card(suit: .hearts, rank: .six)
])
#expect(hand.canDoubleDown, "Should be able to double down with 2 cards")
// Cannot double down after already doubled
hand.isDoubledDown = true
#expect(!hand.canDoubleDown, "Should not be able to double down twice")
// Cannot double down with 3+ cards
hand = BlackjackHand(cards: [
Card(suit: .spades, rank: .five),
Card(suit: .hearts, rank: .six),
Card(suit: .clubs, rank: .two)
])
#expect(!hand.canDoubleDown, "Should not be able to double down with 3 cards")
}
@Test @MainActor func handHitDetection() async throws {
// Can hit with normal hand
var hand = BlackjackHand(cards: [
Card(suit: .spades, rank: .five),
Card(suit: .hearts, rank: .six)
])
#expect(hand.canHit, "Should be able to hit with 11")
// Cannot hit after standing
hand.isStanding = true
#expect(!hand.canHit, "Should not be able to hit after standing")
// Cannot hit when busted
hand = BlackjackHand(cards: [
Card(suit: .spades, rank: .king),
Card(suit: .hearts, rank: .queen),
Card(suit: .clubs, rank: .five)
])
#expect(!hand.canHit, "Should not be able to hit when busted")
// Cannot hit with blackjack
hand = BlackjackHand(cards: [
Card(suit: .spades, rank: .ace),
Card(suit: .hearts, rank: .king)
])
#expect(!hand.canHit, "Should not be able to hit with blackjack")
}
// MARK: - Card Value Tests
@Test func cardHiLoValues() async throws {
// Low cards (2-6) = +1
#expect(Card(suit: .spades, rank: .two).hiLoValue == 1)
#expect(Card(suit: .hearts, rank: .three).hiLoValue == 1)
#expect(Card(suit: .clubs, rank: .four).hiLoValue == 1)
#expect(Card(suit: .diamonds, rank: .five).hiLoValue == 1)
#expect(Card(suit: .spades, rank: .six).hiLoValue == 1)
// Neutral cards (7-9) = 0
#expect(Card(suit: .hearts, rank: .seven).hiLoValue == 0)
#expect(Card(suit: .clubs, rank: .eight).hiLoValue == 0)
#expect(Card(suit: .diamonds, rank: .nine).hiLoValue == 0)
// High cards (10, J, Q, K, A) = -1
#expect(Card(suit: .spades, rank: .ten).hiLoValue == -1)
#expect(Card(suit: .hearts, rank: .jack).hiLoValue == -1)
#expect(Card(suit: .clubs, rank: .queen).hiLoValue == -1)
#expect(Card(suit: .diamonds, rank: .king).hiLoValue == -1)
#expect(Card(suit: .spades, rank: .ace).hiLoValue == -1)
}
// MARK: - Game State Tests
@Test @MainActor func gameStateInitialization() async throws {
let state = GameState(settings: GameSettings())
// Initial state
#expect(state.balance == 10000, "Should start with $10,000")
#expect(state.currentBet == 0, "Should start with no bet")
#expect(state.playerHands.isEmpty, "Should start with no hands")
#expect(state.dealerHand.cards.isEmpty, "Should start with empty dealer hand")
// Check phase enum
switch state.currentPhase {
case .betting:
break // Expected
default:
Issue.record("Should start in betting phase")
}
}
@Test @MainActor func gameStateBetting() async throws {
let state = GameState(settings: GameSettings())
// Place a bet
state.placeBet(amount: 100)
#expect(state.currentBet == 100, "Bet should be placed")
#expect(state.balance == 9900, "Balance should decrease")
// Add to bet
state.placeBet(amount: 50)
#expect(state.currentBet == 150, "Bet should increase")
#expect(state.balance == 9850, "Balance should decrease more")
// Clear bet
state.clearBet()
#expect(state.currentBet == 0, "Bet should be cleared")
#expect(state.balance == 10000, "Balance should be restored")
}
@Test @MainActor func gameStateDealing() async throws {
let state = GameState(settings: GameSettings())
// Place bet and deal
state.placeBet(amount: 100)
await state.deal()
// Verify hands were dealt
#expect(state.playerHands.count == 1, "Should have 1 player hand")
#expect(state.playerHands[0].cards.count == 2, "Player should have 2 cards")
#expect(state.dealerHand.cards.count == 2, "Dealer should have 2 cards")
// Verify values are valid
#expect(state.playerHands[0].value >= 2, "Player hand should have valid value")
#expect(state.playerHands[0].value <= 21, "Player hand should not exceed 21 initially")
}
}

View File

@ -9,28 +9,205 @@ import XCTest
final class BlackjackUITests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
app = XCUIApplication()
app.launchArguments = ["UI-TESTING"]
// In UI tests it's important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
app = nil
}
@MainActor
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
// MARK: - UI Navigation Tests
@MainActor
func testAppLaunches() throws {
app.launch()
XCTAssertTrue(app.exists, "App should launch successfully")
}
@MainActor
func testTopBarElements() throws {
app.launch()
// Wait for the app to load
let balanceText = app.staticTexts.matching(identifier: "balance").firstMatch
XCTAssertTrue(balanceText.waitForExistence(timeout: 2), "Balance should be visible")
// Check settings button exists
let settingsButton = app.buttons.matching(identifier: "settings").firstMatch
XCTAssertTrue(settingsButton.exists, "Settings button should exist")
// Check help button exists
let helpButton = app.buttons.matching(identifier: "help").firstMatch
XCTAssertTrue(helpButton.exists, "Help button should exist")
// Check stats button exists
let statsButton = app.buttons.matching(identifier: "stats").firstMatch
XCTAssertTrue(statsButton.exists, "Stats button should exist")
}
@MainActor
func testSettingsSheet() throws {
app.launch()
// Find and tap settings button
let settingsButton = app.buttons.matching(identifier: "settings").firstMatch
XCTAssertTrue(settingsButton.waitForExistence(timeout: 2), "Settings button should exist")
settingsButton.tap()
// Wait for settings sheet to appear
let settingsSheet = app.scrollViews.firstMatch
XCTAssertTrue(settingsSheet.waitForExistence(timeout: 2), "Settings sheet should appear")
// Check for some expected elements in settings
XCTAssertTrue(app.staticTexts["Table Limits"].waitForExistence(timeout: 1) ||
app.staticTexts["Game Settings"].waitForExistence(timeout: 1),
"Settings content should be visible")
// Close settings (tap outside or dismiss button)
if app.buttons["Done"].exists {
app.buttons["Done"].tap()
} else {
// Tap outside to dismiss
app.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.1)).tap()
}
}
@MainActor
func testHelpSheet() throws {
app.launch()
// Find and tap help button
let helpButton = app.buttons.matching(identifier: "help").firstMatch
XCTAssertTrue(helpButton.waitForExistence(timeout: 2), "Help button should exist")
helpButton.tap()
// Wait for help sheet to appear
let helpSheet = app.scrollViews.firstMatch
XCTAssertTrue(helpSheet.waitForExistence(timeout: 2), "Help sheet should appear")
// Check for rules content
XCTAssertTrue(app.staticTexts["Rules"].exists ||
app.staticTexts["How to Play"].exists,
"Rules content should be visible")
}
// MARK: - Betting Tests
@MainActor
func testChipSelector() throws {
app.launch()
// Wait for chip selector to appear
let chipSelector = app.otherElements["chip-selector"]
XCTAssertTrue(chipSelector.waitForExistence(timeout: 2) ||
app.buttons.matching(NSPredicate(format: "label CONTAINS '$'")).count > 0,
"Chip selector should be visible")
// Find a chip button (they should have $ in the label)
let chipButtons = app.buttons.matching(NSPredicate(format: "label CONTAINS '$'"))
XCTAssertGreaterThan(chipButtons.count, 0, "Should have chip buttons")
}
@MainActor
func testPlaceBet() throws {
app.launch()
// Wait for betting area
sleep(1)
// Find deal button (should be disabled initially)
let dealButton = app.buttons["Deal"]
XCTAssertTrue(dealButton.waitForExistence(timeout: 2), "Deal button should exist")
// Initially deal button should be disabled
XCTAssertFalse(dealButton.isEnabled, "Deal button should be disabled before bet")
// Tap on betting zone
let bettingZone = app.otherElements["betting-zone"]
if bettingZone.exists {
bettingZone.tap()
// Deal button should now be enabled (or still disabled if bet is below minimum)
// We can't reliably test this without knowing the bet amount vs minimum
}
}
@MainActor
func testClearButton() throws {
app.launch()
// Wait and place a bet by tapping betting zone
sleep(1)
let bettingZone = app.otherElements["betting-zone"]
if bettingZone.exists {
bettingZone.tap()
}
// Find clear button
let clearButton = app.buttons["Clear"]
if clearButton.exists {
clearButton.tap()
// After clearing, deal button should be disabled again
let dealButton = app.buttons["Deal"]
XCTAssertFalse(dealButton.isEnabled, "Deal button should be disabled after clear")
}
}
// MARK: - Gameplay Tests
@MainActor
func testDealCards() throws {
app.launch()
sleep(1)
// Place a bet by tapping betting zone multiple times to ensure we meet minimum
let bettingZone = app.otherElements["betting-zone"]
if bettingZone.exists {
for _ in 1...5 {
bettingZone.tap()
}
}
// Find and tap deal button
let dealButton = app.buttons["Deal"]
if dealButton.waitForExistence(timeout: 2) && dealButton.isEnabled {
dealButton.tap()
// Wait for cards to be dealt
sleep(2)
// After dealing, we should see hit/stand buttons or a result
let hitButton = app.buttons["Hit"]
let standButton = app.buttons["Stand"]
let newRoundButton = app.buttons["New Round"]
XCTAssertTrue(hitButton.exists || standButton.exists || newRoundButton.exists,
"Should see action buttons after dealing")
}
}
@MainActor
func testLaunchPerformance() throws {
// This measures how long it takes to launch your application.