Files
Tirsvad 3c99040b96 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
2026-10-04 22:04:40 +08:00

234 lines
7.1 KiB
Python

"""!
@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()