diff --git a/Baccarat/Baccarat/Engine/GameState.swift b/Baccarat/Baccarat/Engine/GameState.swift index eb07d16..90533c6 100644 --- a/Baccarat/Baccarat/Engine/GameState.swift +++ b/Baccarat/Baccarat/Engine/GameState.swift @@ -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) diff --git a/Baccarat/Baccarat/Views/Game/GameTableView.swift b/Baccarat/Baccarat/Views/Game/GameTableView.swift index 248e6a5..5cc6b61 100644 --- a/Baccarat/Baccarat/Views/Game/GameTableView.swift +++ b/Baccarat/Baccarat/Views/Game/GameTableView.swift @@ -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") } ) } diff --git a/Baccarat/Baccarat/Views/Table/CardsDisplayArea.swift b/Baccarat/Baccarat/Views/Table/CardsDisplayArea.swift index 94308f0..7777350 100644 --- a/Baccarat/Baccarat/Views/Table/CardsDisplayArea.swift +++ b/Baccarat/Baccarat/Views/Table/CardsDisplayArea.swift @@ -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) diff --git a/Baccarat/BaccaratUITests/BaccaratUITests.swift b/Baccarat/BaccaratUITests/BaccaratUITests.swift index 5c003c4..a45e06e 100644 --- a/Baccarat/BaccaratUITests/BaccaratUITests.swift +++ b/Baccarat/BaccaratUITests/BaccaratUITests.swift @@ -8,28 +8,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 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. + + 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 { diff --git a/Blackjack/Blackjack/Engine/GameState.swift b/Blackjack/Blackjack/Engine/GameState.swift index d186848..058d97f 100644 --- a/Blackjack/Blackjack/Engine/GameState.swift +++ b/Blackjack/Blackjack/Engine/GameState.swift @@ -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 diff --git a/Blackjack/Blackjack/Views/Game/GameTableView.swift b/Blackjack/Blackjack/Views/Game/GameTableView.swift index ba9f087..d34d5a7 100644 --- a/Blackjack/Blackjack/Views/Game/GameTableView.swift +++ b/Blackjack/Blackjack/Views/Game/GameTableView.swift @@ -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") } ) } diff --git a/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift b/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift index 618a5d4..2ee9937 100644 --- a/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift +++ b/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift @@ -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 ) diff --git a/Blackjack/Blackjack/Views/Table/DealerHandView.swift b/Blackjack/Blackjack/Views/Table/DealerHandView.swift index f8fbe0f..a609bc1 100644 --- a/Blackjack/Blackjack/Views/Table/DealerHandView.swift +++ b/Blackjack/Blackjack/Views/Table/DealerHandView.swift @@ -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 ) } } diff --git a/Blackjack/Blackjack/Views/Table/PlayerHandView.swift b/Blackjack/Blackjack/Views/Table/PlayerHandView.swift index a50193f..37bcdb3 100644 --- a/Blackjack/Blackjack/Views/Table/PlayerHandView.swift +++ b/Blackjack/Blackjack/Views/Table/PlayerHandView.swift @@ -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 ) diff --git a/Blackjack/BlackjackTests/BlackjackTests.swift b/Blackjack/BlackjackTests/BlackjackTests.swift index ad87f58..20bcad5 100644 --- a/Blackjack/BlackjackTests/BlackjackTests.swift +++ b/Blackjack/BlackjackTests/BlackjackTests.swift @@ -7,11 +7,281 @@ import Testing @testable import Blackjack +import CasinoKit struct BlackjackTests { @Test func example() async throws { // 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") + } } diff --git a/Blackjack/BlackjackUITests/BlackjackUITests.swift b/Blackjack/BlackjackUITests/BlackjackUITests.swift index 8a3cda9..f4a0e70 100644 --- a/Blackjack/BlackjackUITests/BlackjackUITests.swift +++ b/Blackjack/BlackjackUITests/BlackjackUITests.swift @@ -8,28 +8,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 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. + + 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 {