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
+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()