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
This commit is contained in:
2026-10-04 22:04:40 +08:00
parent 3381855019
commit 3c99040b96
10 changed files with 737 additions and 16 deletions
+233
View File
@@ -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()
+2 -2
View File
@@ -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__":
+151
View File
@@ -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()