From 3c99040b96efdd2c5b5d9015466a4145d0da37f6 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Sun, 4 Oct 2026 22:04:40 +0800 Subject: [PATCH] Add Blackjack game: rules, card rendering, game loop and tests Implement the playable console game for gateway MIL-002. - rules.py: deal_card, calculate_score (blackjack = 0, aces drop 11 -> 1) and compare with an Outcome enum; no console I/O - display.py: hand rendering as plain-text rank plus suit emoji (keycap emoji rendered as boxes in common terminals), score formatting and outcome messages - art.py: course logo shown at the start of every game - game.py: play_game (hit/stand, dealer draws below 17), play (restart loop, console clear, logo) and ask_yes_no with re-prompt; read, write and draw are injected so games can be scripted - __main__.py: python -m blackjack entry point, exits cleanly on Ctrl+C/Ctrl+D - constants.py: card ranks and suits, prompts and messages - tests: 32 new unit and scripted end-to-end tests (35 in total) - README: run instructions for python -m blackjack Closes #7 Closes #8 Closes #9 Closes #10 Closes #11 Closes #12 Closes #13 Closes #14 Task: MIL-002#1 Task: MIL-002#2 Task: MIL-002#3 Task: MIL-002#4 Task: MIL-002#5 Task: MIL-002#6 Task: MIL-002#7 Task: MIL-002#8 --- README.md | 2 +- src/blackjack/__main__.py | 21 ++++ src/blackjack/art.py | 16 +++ src/blackjack/constants.py | 59 +++++++--- src/blackjack/display.py | 61 ++++++++++ src/blackjack/game.py | 128 ++++++++++++++++++++ src/blackjack/rules.py | 78 +++++++++++++ tests/test_game.py | 233 +++++++++++++++++++++++++++++++++++++ tests/test_package.py | 4 +- tests/test_rules.py | 151 ++++++++++++++++++++++++ 10 files changed, 737 insertions(+), 16 deletions(-) create mode 100644 src/blackjack/__main__.py create mode 100644 src/blackjack/art.py create mode 100644 src/blackjack/display.py create mode 100644 src/blackjack/game.py create mode 100644 src/blackjack/rules.py create mode 100644 tests/test_game.py create mode 100644 tests/test_rules.py diff --git a/README.md b/README.md index 3dd4677..c334f8a 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Leave the environment with `deactivate`. ## Run -The game entry point is added in the *Game Implementation* milestone: +Start the game from the activated virtual environment: ```bash python -m blackjack diff --git a/src/blackjack/__main__.py b/src/blackjack/__main__.py new file mode 100644 index 0000000..08b69ae --- /dev/null +++ b/src/blackjack/__main__.py @@ -0,0 +1,21 @@ +"""! +@file __main__.py +@brief Entry point: python -m blackjack. +""" + +from blackjack.constants import MESSAGE_GOODBYE +from blackjack.game import play + + +def main() -> None: + """! + @brief Run the game; leave quietly on Ctrl+C or Ctrl+D. + """ + try: + play() + except (EOFError, KeyboardInterrupt): + print(f"\n{MESSAGE_GOODBYE}") + + +if __name__ == "__main__": + main() diff --git a/src/blackjack/art.py b/src/blackjack/art.py new file mode 100644 index 0000000..e4b9b2d --- /dev/null +++ b/src/blackjack/art.py @@ -0,0 +1,16 @@ +"""! +@file art.py +@brief ASCII art shown at the start of every game. +""" + +## The game logo from the course assignment. +LOGO: str = r""" +.------. _ _ _ _ _ +|A_ _ |. | | | | | | (_) | | +|( \/ ).-----. | |__ | | __ _ ___| | ___ __ _ ___| | __ +| \ /|K /\ | | '_ \| |/ _` |/ __| |/ / |/ _` |/ __| |/ / +| \/ | / \ | | |_) | | (_| | (__| <| | (_| | (__| < +`-----| \ / | |_.__/|_|\__,_|\___|_|\_\ |\__,_|\___|_|\_\\ + | \/ K| _/ | + `------' |__/ +""" diff --git a/src/blackjack/constants.py b/src/blackjack/constants.py index f429188..bf7e63e 100644 --- a/src/blackjack/constants.py +++ b/src/blackjack/constants.py @@ -24,17 +24,50 @@ BLACKJACK_MARKER: int = 0 ## Unlimited deck: cards are never removed. 11 is the ace, 10 covers 10/J/Q/K. CARDS: list[int] = [11, 2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 10, 10] -## Emoji face for each card value. -CARD_FACES: dict[int, str] = { - ACE_HIGH: "\U0001f170️", - ACE_LOW: "\U0001f170️", - 2: "2️⃣", - 3: "3️⃣", - 4: "4️⃣", - 5: "5️⃣", - 6: "6️⃣", - 7: "7️⃣", - 8: "8️⃣", - 9: "9️⃣", - 10: "\U0001f51f", +## Rank text for each card value; plain text renders in every terminal. +CARD_RANKS: dict[int, str] = { + ACE_HIGH: "A", + ACE_LOW: "A", + 2: "2", + 3: "3", + 4: "4", + 5: "5", + 6: "6", + 7: "7", + 8: "8", + 9: "9", + 10: "10", } + +## Suit emoji shown after the rank; cards in a hand cycle through them. +CARD_SUITS: tuple[str, ...] = ("♠️", "♥️", "♦️", "♣️") + +## Answer that means yes at a yes/no prompt. +ANSWER_YES: str = "y" + +## Answer that means no at a yes/no prompt. +ANSWER_NO: str = "n" + +## Prompt asking the player whether to draw another card. +PROMPT_HIT: str = "Type 'y' to get another card, type 'n' to pass: " + +## Prompt asking the player whether to play a game. +PROMPT_RESTART: str = "Do you want to play a game of Blackjack? Type 'y' or 'n': " + +## Message shown after an answer that is neither yes nor no. +MESSAGE_INVALID_ANSWER: str = "Please answer 'y' or 'n'." + +## Message shown when the player leaves the game. +MESSAGE_GOODBYE: str = "Goodbye!" + +## Label shown instead of a score when a hand is a blackjack. +LABEL_BLACKJACK: str = "Blackjack" + +## Outcome messages. +MESSAGE_DRAW: str = "Draw \U0001f643" +MESSAGE_WIN: str = "You win \U0001f603" +MESSAGE_WIN_BLACKJACK: str = "Win with a Blackjack \U0001f60e" +MESSAGE_WIN_DEALER_BUST: str = "Opponent went over. You win \U0001f601" +MESSAGE_LOSE: str = "You lose \U0001f624" +MESSAGE_LOSE_DEALER_BLACKJACK: str = "Lose, opponent has Blackjack \U0001f631" +MESSAGE_LOSE_BUST: str = "You went over. You lose \U0001f62d" diff --git a/src/blackjack/display.py b/src/blackjack/display.py new file mode 100644 index 0000000..e3958a9 --- /dev/null +++ b/src/blackjack/display.py @@ -0,0 +1,61 @@ +"""! +@file display.py +@brief Turns cards, scores and outcomes into text for the console. +""" + +from collections.abc import Sequence + +from blackjack.constants import ( + BLACKJACK_MARKER, + CARD_RANKS, + CARD_SUITS, + LABEL_BLACKJACK, + MESSAGE_DRAW, + MESSAGE_LOSE, + MESSAGE_LOSE_BUST, + MESSAGE_LOSE_DEALER_BLACKJACK, + MESSAGE_WIN, + MESSAGE_WIN_BLACKJACK, + MESSAGE_WIN_DEALER_BUST, +) +from blackjack.rules import Outcome + +_OUTCOME_MESSAGES: dict[Outcome, str] = { + Outcome.DRAW: MESSAGE_DRAW, + Outcome.WIN: MESSAGE_WIN, + Outcome.WIN_BLACKJACK: MESSAGE_WIN_BLACKJACK, + Outcome.WIN_DEALER_BUST: MESSAGE_WIN_DEALER_BUST, + Outcome.LOSE: MESSAGE_LOSE, + Outcome.LOSE_DEALER_BLACKJACK: MESSAGE_LOSE_DEALER_BLACKJACK, + Outcome.LOSE_BUST: MESSAGE_LOSE_BUST, +} + + +def render_hand(cards: Sequence[int]) -> str: + """! + @brief Show a hand as emoji cards. + @param cards The card values in the hand. + @return The emoji faces separated by spaces. + """ + return " ".join( + f"{CARD_RANKS[card]}{CARD_SUITS[index % len(CARD_SUITS)]}" + for index, card in enumerate(cards) + ) + + +def format_score(score: int) -> str: + """! + @brief Show a score for the player. + @param score A score from calculate_score (0 means blackjack). + @return The number, or the blackjack label. + """ + return LABEL_BLACKJACK if score == BLACKJACK_MARKER else str(score) + + +def outcome_message(outcome: Outcome) -> str: + """! + @brief Message announcing how the game ended. + @param outcome The outcome seen from the player. + @return The text to show. + """ + return _OUTCOME_MESSAGES[outcome] diff --git a/src/blackjack/game.py b/src/blackjack/game.py new file mode 100644 index 0000000..2403dd3 --- /dev/null +++ b/src/blackjack/game.py @@ -0,0 +1,128 @@ +"""! +@file game.py +@brief Console game flow: one game, the restart loop and console clearing. +""" + +import os +from collections.abc import Callable + +from blackjack.art import LOGO +from blackjack.constants import ( + ANSWER_NO, + ANSWER_YES, + BLACKJACK_MARKER, + BLACKJACK_SCORE, + DEALER_STAND_SCORE, + MESSAGE_GOODBYE, + MESSAGE_INVALID_ANSWER, + OPENING_HAND_SIZE, + PROMPT_HIT, + PROMPT_RESTART, +) +from blackjack.display import format_score, outcome_message, render_hand +from blackjack.rules import Outcome, calculate_score, compare, deal_card + +## Reads one answer from the player. +type Reader = Callable[[str], str] +## Shows one line to the player. +type Writer = Callable[[str], None] +## Draws one card. +type Draw = Callable[[], int] + + +def clear_console() -> None: + """! + @brief Clear the terminal screen. + """ + os.system("cls" if os.name == "nt" else "clear") + + +def ask_yes_no(prompt: str, read: Reader, write: Writer) -> bool: + """! + @brief Ask until the player answers yes or no. + @param prompt The question to show. + @param read Reads the player's answer. + @param write Shows a message to the player. + @return True for yes, False for no. + """ + while True: + answer = read(prompt).strip().lower() + if answer == ANSWER_YES: + return True + if answer == ANSWER_NO: + return False + write(MESSAGE_INVALID_ANSWER) + + +def play_game(read: Reader, write: Writer, draw: Draw) -> Outcome: + """! + @brief Play one game: the player draws, then the dealer, then compare. + @param read Reads the player's answers. + @param write Shows text to the player. + @param draw Draws one card. + @return The outcome seen from the player. + """ + user_cards = [draw() for _ in range(OPENING_HAND_SIZE)] + computer_cards = [draw() for _ in range(OPENING_HAND_SIZE)] + computer_score = 0 + user_score = 0 + is_game_over = False + while not is_game_over: + user_score = calculate_score(user_cards) + computer_score = calculate_score(computer_cards) + write( + f" Your cards: {render_hand(user_cards)}, " + f"current score: {format_score(user_score)}" + ) + write(f" Dealer's first card: {render_hand(computer_cards[:1])}") + if ( + user_score == BLACKJACK_MARKER + or computer_score == BLACKJACK_MARKER + or user_score > BLACKJACK_SCORE + ): + is_game_over = True + elif ask_yes_no(PROMPT_HIT, read, write): + user_cards.append(draw()) + else: + is_game_over = True + + is_user_bust = user_score > BLACKJACK_SCORE + while ( + not is_user_bust + and computer_score != BLACKJACK_MARKER + and computer_score < DEALER_STAND_SCORE + ): + computer_cards.append(draw()) + computer_score = calculate_score(computer_cards) + + write( + f" Your final hand: {render_hand(user_cards)}, " + f"final score: {format_score(user_score)}" + ) + write( + f" Dealer's final hand: {render_hand(computer_cards)}, " + f"final score: {format_score(computer_score)}" + ) + outcome = compare(user_score, computer_score) + write(outcome_message(outcome)) + return outcome + + +def play( + read: Reader = input, + write: Writer = print, + draw: Draw = deal_card, + clear: Callable[[], None] = clear_console, +) -> None: + """! + @brief Play games until the player does not want another one. + @param read Reads the player's answers. + @param write Shows text to the player. + @param draw Draws one card. + @param clear Clears the screen before each game. + """ + while ask_yes_no(PROMPT_RESTART, read, write): + clear() + write(LOGO) + play_game(read, write, draw) + write(MESSAGE_GOODBYE) diff --git a/src/blackjack/rules.py b/src/blackjack/rules.py new file mode 100644 index 0000000..85101c0 --- /dev/null +++ b/src/blackjack/rules.py @@ -0,0 +1,78 @@ +"""! +@file rules.py +@brief Blackjack house rules as pure functions; no console input or output. +""" + +import random +from collections.abc import Sequence +from enum import Enum + +from blackjack.constants import ( + ACE_HIGH, + ACE_LOW, + BLACKJACK_MARKER, + BLACKJACK_SCORE, + CARDS, + OPENING_HAND_SIZE, +) + + +class Outcome(Enum): + """! + @brief How a game ended, seen from the player. + """ + + DRAW = "draw" + WIN = "win" + WIN_BLACKJACK = "win_blackjack" + WIN_DEALER_BUST = "win_dealer_bust" + LOSE = "lose" + LOSE_DEALER_BLACKJACK = "lose_dealer_blackjack" + LOSE_BUST = "lose_bust" + + +def deal_card() -> int: + """! + @brief Draw one card from the unlimited deck. + @return A card value; 11 is the ace and 10 covers 10, Jack, Queen and King. + """ + return random.choice(CARDS) + + +def calculate_score(cards: Sequence[int]) -> int: + """! + @brief Score a hand. + @param cards The card values in the hand. + @return The total, or 0 for a blackjack (two cards: an ace and a 10). + An ace counts 11 unless that would bust the hand, then it counts 1. + """ + hand = list(cards) + total = sum(hand) + if len(hand) == OPENING_HAND_SIZE and total == BLACKJACK_SCORE: + return BLACKJACK_MARKER + while total > BLACKJACK_SCORE and ACE_HIGH in hand: + hand[hand.index(ACE_HIGH)] = ACE_LOW + total = sum(hand) + return total + + +def compare(user_score: int, computer_score: int) -> Outcome: + """! + @brief Decide who won. + @param user_score The player's score (0 means blackjack). + @param computer_score The dealer's score (0 means blackjack). + @return The outcome seen from the player. + """ + if user_score == computer_score: + return Outcome.DRAW + if computer_score == BLACKJACK_MARKER: + return Outcome.LOSE_DEALER_BLACKJACK + if user_score == BLACKJACK_MARKER: + return Outcome.WIN_BLACKJACK + if user_score > BLACKJACK_SCORE: + return Outcome.LOSE_BUST + if computer_score > BLACKJACK_SCORE: + return Outcome.WIN_DEALER_BUST + if user_score > computer_score: + return Outcome.WIN + return Outcome.LOSE diff --git a/tests/test_game.py b/tests/test_game.py new file mode 100644 index 0000000..229a73d --- /dev/null +++ b/tests/test_game.py @@ -0,0 +1,233 @@ +"""! +@file test_game.py +@brief Tests for rendering and the console game flow, with scripted input. +""" + +import ast +import inspect +import unittest +from collections.abc import Iterator + +from blackjack import rules +from blackjack.art import LOGO +from blackjack.constants import ( + CARD_SUITS, + DEALER_STAND_SCORE, + MESSAGE_GOODBYE, + MESSAGE_INVALID_ANSWER, +) +from blackjack.display import format_score, outcome_message, render_hand +from blackjack.game import ask_yes_no, play, play_game +from blackjack.rules import Outcome + + +class Script: + """! + @brief Scripted console: queued answers, queued cards, recorded output. + """ + + def __init__(self, answers: list[str], cards: list[int]) -> None: + """! + @param answers The answers the player types, in order. + @param cards The cards drawn, in order. + """ + self._answers: Iterator[str] = iter(answers) + self._cards: Iterator[int] = iter(cards) + self.output: list[str] = [] + self.clears = 0 + + def read(self, prompt: str) -> str: + """! + @brief Next scripted answer. + """ + return next(self._answers) + + def write(self, text: str) -> None: + """! + @brief Record a line of output. + """ + self.output.append(text) + + def draw(self) -> int: + """! + @brief Next scripted card. + """ + return next(self._cards) + + def clear(self) -> None: + """! + @brief Count a console clear. + """ + self.clears += 1 + + @property + def text(self) -> str: + """! + @brief All output as one string. + """ + return "\n".join(self.output) + + +class TestDisplay(unittest.TestCase): + """! + @brief Tests for the rendering helpers. + """ + + def test_render_hand_shows_rank_and_suit(self) -> None: + """! + @brief Each card is its rank followed by a suit emoji, joined by spaces. + """ + self.assertEqual( + render_hand([11, 10, 7]), + f"A{CARD_SUITS[0]} 10{CARD_SUITS[1]} 7{CARD_SUITS[2]}", + ) + + def test_format_score_blackjack(self) -> None: + """! + @brief Score 0 is shown as Blackjack, other scores as numbers. + """ + self.assertEqual(format_score(0), "Blackjack") + self.assertEqual(format_score(17), "17") + + def test_every_outcome_has_a_message(self) -> None: + """! + @brief No outcome is left without a message. + """ + for outcome in Outcome: + self.assertTrue(outcome_message(outcome)) + + +class TestAskYesNo(unittest.TestCase): + """! + @brief Tests for ask_yes_no. + """ + + def test_accepts_yes_and_no(self) -> None: + """! + @brief Answers are trimmed and case-insensitive. + """ + script = Script([" Y ", "N"], []) + self.assertTrue(ask_yes_no("?", script.read, script.write)) + self.assertFalse(ask_yes_no("?", script.read, script.write)) + + def test_repeats_on_invalid_answer(self) -> None: + """! + @brief An invalid answer is rejected and the question is asked again. + """ + script = Script(["maybe", "y"], []) + self.assertTrue(ask_yes_no("?", script.read, script.write)) + self.assertEqual(script.output, [MESSAGE_INVALID_ANSWER]) + + +class TestPlayGame(unittest.TestCase): + """! + @brief Scripted games for each way a game can end. + """ + + def run_game(self, answers: list[str], cards: list[int]) -> tuple[Outcome, Script]: + """! + @brief Play one scripted game. + """ + script = Script(answers, cards) + outcome = play_game(script.read, script.write, script.draw) + return outcome, script + + def test_stand_and_win(self) -> None: + """! + @brief Stand on 19; dealer draws from 12 to 22 and busts. + """ + # user 10+9, dealer 10+2, dealer draws 10 + outcome, _ = self.run_game(["n"], [10, 9, 10, 2, 10]) + self.assertEqual(outcome, Outcome.WIN_DEALER_BUST) + + def test_hit_then_bust(self) -> None: + """! + @brief Hitting on 16 and drawing a 10 busts; the dealer does not draw. + """ + # user 10+6, dealer 10+7, user hits 10 + outcome, script = self.run_game(["y"], [10, 6, 10, 7, 10]) + self.assertEqual(outcome, Outcome.LOSE_BUST) + self.assertIn("Your final hand", script.text) + + def test_player_blackjack_ends_without_prompt(self) -> None: + """! + @brief A player blackjack ends the game before any question. + """ + outcome, _ = self.run_game([], [11, 10, 10, 9]) + self.assertEqual(outcome, Outcome.WIN_BLACKJACK) + + def test_dealer_blackjack(self) -> None: + """! + @brief A dealer blackjack ends the game and the player loses. + """ + outcome, _ = self.run_game([], [10, 9, 11, 10]) + self.assertEqual(outcome, Outcome.LOSE_DEALER_BLACKJACK) + + def test_dealer_draws_until_stand_score(self) -> None: + """! + @brief The dealer draws below 17 and stops at 17 or more. + """ + # user 10+8 stands; dealer 2+3, draws 4 (9), 5 (14), 3 (17) + outcome, script = self.run_game(["n"], [10, 8, 2, 3, 4, 5, 3]) + self.assertEqual(outcome, Outcome.WIN) + self.assertIn(f"final score: {DEALER_STAND_SCORE}", script.text) + + def test_draw(self) -> None: + """! + @brief Equal final scores are a draw. + """ + outcome, _ = self.run_game(["n"], [10, 8, 10, 8]) + self.assertEqual(outcome, Outcome.DRAW) + + +class TestPlay(unittest.TestCase): + """! + @brief Scripted end-to-end sessions including restart. + """ + + def test_full_session_with_restart(self) -> None: + """! + @brief Two games: hit-and-stand, then a restart answer of no. + """ + script = Script( + answers=["y", "y", "n", "y", "n"], + # game 1: user 5+5, dealer 10+8; user hits 10 (20), stands; dealer stands 18 + # game 2: blackjack for the player + cards=[5, 5, 10, 8, 10, 11, 10, 10, 9], + ) + play(script.read, script.write, script.draw, script.clear) + self.assertEqual(script.clears, 2) + self.assertEqual(script.text.count(LOGO), 2) + self.assertIn(outcome_message(Outcome.WIN), script.text) + self.assertIn(outcome_message(Outcome.WIN_BLACKJACK), script.text) + self.assertEqual(script.output[-1], MESSAGE_GOODBYE) + + def test_quit_straight_away(self) -> None: + """! + @brief Answering no at the first prompt plays no game. + """ + script = Script(["n"], []) + play(script.read, script.write, script.draw, script.clear) + self.assertEqual(script.clears, 0) + + +class TestRulesAreFreeOfConsoleIo(unittest.TestCase): + """! + @brief The rule functions must not read or print. + """ + + def test_no_input_or_print_in_rules(self) -> None: + """! + @brief blackjack.rules contains no call to input() or print(). + """ + tree = ast.parse(inspect.getsource(rules)) + called = { + node.func.id + for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + } + self.assertFalse(called & {"input", "print"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_package.py b/tests/test_package.py index e9d5574..c3dd189 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -28,10 +28,10 @@ class TestPackage(unittest.TestCase): def test_every_card_has_an_emoji_face(self) -> None: """! - @brief Every card in the deck can be rendered. + @brief Every card in the deck has a rank to render. """ for card in constants.CARDS: - self.assertIn(card, constants.CARD_FACES) + self.assertIn(card, constants.CARD_RANKS) if __name__ == "__main__": diff --git a/tests/test_rules.py b/tests/test_rules.py new file mode 100644 index 0000000..d867dc5 --- /dev/null +++ b/tests/test_rules.py @@ -0,0 +1,151 @@ +"""! +@file test_rules.py +@brief Unit tests for the house rules in blackjack.rules. +""" + +import unittest +from unittest.mock import patch + +from blackjack.constants import CARDS +from blackjack.rules import Outcome, calculate_score, compare, deal_card + + +class TestDealCard(unittest.TestCase): + """! + @brief Tests for deal_card. + """ + + def test_returns_card_from_deck(self) -> None: + """! + @brief Every draw is a card of the deck. + """ + for _ in range(200): + self.assertIn(deal_card(), CARDS) + + def test_uses_random_choice_on_the_deck(self) -> None: + """! + @brief The card comes from random.choice over the deck constant. + """ + with patch("blackjack.rules.random.choice", return_value=7) as choice: + self.assertEqual(deal_card(), 7) + choice.assert_called_once_with(CARDS) + + def test_deck_is_not_consumed(self) -> None: + """! + @brief Drawing does not remove cards from the deck. + """ + before = list(CARDS) + for _ in range(50): + deal_card() + self.assertEqual(CARDS, before) + + +class TestCalculateScore(unittest.TestCase): + """! + @brief Tests for calculate_score. + """ + + def test_normal_total(self) -> None: + """! + @brief A plain hand scores the sum of its cards. + """ + self.assertEqual(calculate_score([2, 3, 9]), 14) + + def test_blackjack_scores_zero(self) -> None: + """! + @brief Ace plus 10 in two cards is a blackjack, in either order. + """ + self.assertEqual(calculate_score([11, 10]), 0) + self.assertEqual(calculate_score([10, 11]), 0) + + def test_three_card_21_is_not_blackjack(self) -> None: + """! + @brief 21 with more than two cards is a normal 21. + """ + self.assertEqual(calculate_score([7, 7, 7]), 21) + + def test_ace_counts_one_when_hand_would_bust(self) -> None: + """! + @brief An ace drops from 11 to 1 when the total exceeds 21. + """ + self.assertEqual(calculate_score([11, 5, 10]), 16) + + def test_two_aces(self) -> None: + """! + @brief Two aces score 12: one counts 11, the other 1. + """ + self.assertEqual(calculate_score([11, 11]), 12) + + def test_only_needed_aces_are_demoted(self) -> None: + """! + @brief Aces drop one at a time, only until the hand no longer busts. + """ + self.assertEqual(calculate_score([11, 11, 9]), 21) + + def test_bust(self) -> None: + """! + @brief A hand over 21 without aces keeps its total. + """ + self.assertEqual(calculate_score([10, 10, 5]), 25) + + def test_does_not_change_the_hand(self) -> None: + """! + @brief Scoring leaves the caller's list unchanged. + """ + hand = [11, 5, 10] + calculate_score(hand) + self.assertEqual(hand, [11, 5, 10]) + + +class TestCompare(unittest.TestCase): + """! + @brief Tests for compare, in the order of the house rules. + """ + + def test_equal_scores_draw(self) -> None: + """! + @brief Equal scores are a draw, also two blackjacks. + """ + self.assertEqual(compare(18, 18), Outcome.DRAW) + self.assertEqual(compare(0, 0), Outcome.DRAW) + + def test_dealer_blackjack_loses(self) -> None: + """! + @brief A dealer blackjack beats the player. + """ + self.assertEqual(compare(20, 0), Outcome.LOSE_DEALER_BLACKJACK) + + def test_user_blackjack_wins(self) -> None: + """! + @brief A player blackjack wins. + """ + self.assertEqual(compare(0, 20), Outcome.WIN_BLACKJACK) + + def test_user_bust_loses(self) -> None: + """! + @brief A player over 21 loses. + """ + self.assertEqual(compare(22, 18), Outcome.LOSE_BUST) + + def test_user_bust_loses_even_when_dealer_busts(self) -> None: + """! + @brief The player's bust is checked before the dealer's. + """ + self.assertEqual(compare(23, 25), Outcome.LOSE_BUST) + + def test_dealer_bust_wins(self) -> None: + """! + @brief A dealer over 21 loses. + """ + self.assertEqual(compare(15, 24), Outcome.WIN_DEALER_BUST) + + def test_higher_score_wins(self) -> None: + """! + @brief Otherwise the higher score wins. + """ + self.assertEqual(compare(19, 18), Outcome.WIN) + self.assertEqual(compare(17, 20), Outcome.LOSE) + + +if __name__ == "__main__": + unittest.main()