7 Commits
Author SHA1 Message Date
Tirsvad 6f75b26b18 Merge pull request 'Resolve pending commit links' (#18) from resolve-pending-commit-links into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 4s
Reviewed-on: #18
2026-10-04 16:13:26 +02:00
Tirsvad 0bf901b471 Resolve pending commit links
Replace `pending` in the Version History of BC-001, SA-001, PP-001,
MIL-001 and MIL-002 with links to commit 1d35410, which introduced
those rows.
2026-10-04 22:11:59 +08:00
Tirsvad 06d0386010 Merge pull request 'Add Blackjack game: rules, card rendering, game loop and tests' (#17) from mil-002-game-implementation into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 5s
Reviewed-on: #17
2026-10-04 16:06:12 +02:00
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
Tirsvad 3381855019 Merge pull request 'Add project setup: layout, pyproject, constants, Doxyfile, README' (#16) from mil-001-project-setup into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 5s
Reviewed-on: #16
2026-10-04 15:50:19 +02:00
Tirsvad 36b5788aef Add project setup: layout, pyproject, constants, Doxyfile, README
Set up the Blackjack project foundation for gateway MIL-001.

- pyproject.toml: Python >=3.13, src layout, no runtime dependencies,
  optional dev extras (ruff, mypy, pytest) and their configuration
- src/blackjack/: package with constants.py (deck, scoring thresholds,
  emoji card faces) and Doxygen-style docstrings
- tests/test_package.py: smoke tests for the package and its constants
- Doxyfile: reads src/, writes HTML to docs/doxygen/
- README.md: venv setup with pip upgrade, run, test, lint and Doxygen
  instructions
- .gitignore: ignore generated docs/doxygen/

The repository description and topics were set on the git host through
its API (no file change).

Closes #1
Closes #2
Closes #3
Closes #4
Closes #5
Closes #6

Task: MIL-001#1
Task: MIL-001#2
Task: MIL-001#3
Task: MIL-001#4
Task: MIL-001#5
Task: MIL-001#6
2026-10-04 21:48:52 +08:00
Tirsvad 97c71fbec2 Merge pull request 'Add planning baseline: BC, SA, PP and two gateways' (#15) from Planning into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
Reviewed-on: #15
2026-10-04 15:39:58 +02:00
19 changed files with 1382 additions and 6 deletions
+3
View File
@@ -174,3 +174,6 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
# Generated Doxygen output
docs/doxygen/
+438
View File
@@ -0,0 +1,438 @@
# Doxyfile 1.15.0
#---------------------------------------------------------------------------
# Project related configuration options
#---------------------------------------------------------------------------
DOXYFILE_ENCODING = UTF-8
PROJECT_NAME = "My Project"
PROJECT_NUMBER =
PROJECT_BRIEF =
PROJECT_LOGO =
PROJECT_ICON =
OUTPUT_DIRECTORY =
CREATE_SUBDIRS = NO
CREATE_SUBDIRS_LEVEL = 8
ALLOW_UNICODE_NAMES = NO
OUTPUT_LANGUAGE = English
BRIEF_MEMBER_DESC = YES
REPEAT_BRIEF = YES
ABBREVIATE_BRIEF = "The $name class" \
"The $name widget" \
"The $name file" \
is \
provides \
specifies \
contains \
represents \
a \
an \
the
ALWAYS_DETAILED_SEC = NO
INLINE_INHERITED_MEMB = NO
FULL_PATH_NAMES = YES
STRIP_FROM_PATH =
STRIP_FROM_INC_PATH =
SHORT_NAMES = NO
JAVADOC_AUTOBRIEF = NO
JAVADOC_BANNER = NO
QT_AUTOBRIEF = NO
MULTILINE_CPP_IS_BRIEF = NO
PYTHON_DOCSTRING = YES
INHERIT_DOCS = YES
SEPARATE_MEMBER_PAGES = NO
TAB_SIZE = 4
ALIASES =
OPTIMIZE_OUTPUT_FOR_C = NO
OPTIMIZE_OUTPUT_JAVA = NO
OPTIMIZE_FOR_FORTRAN = NO
OPTIMIZE_OUTPUT_VHDL = NO
OPTIMIZE_OUTPUT_SLICE = NO
EXTENSION_MAPPING =
MARKDOWN_SUPPORT = YES
MARKDOWN_STRICT = YES
TOC_INCLUDE_HEADINGS = 6
MARKDOWN_ID_STYLE = DOXYGEN
AUTOLINK_SUPPORT = YES
AUTOLINK_IGNORE_WORDS =
BUILTIN_STL_SUPPORT = NO
CPP_CLI_SUPPORT = NO
SIP_SUPPORT = NO
IDL_PROPERTY_SUPPORT = YES
DISTRIBUTE_GROUP_DOC = NO
GROUP_NESTED_COMPOUNDS = NO
SUBGROUPING = YES
INLINE_GROUPED_CLASSES = NO
INLINE_SIMPLE_STRUCTS = NO
TYPEDEF_HIDES_STRUCT = NO
LOOKUP_CACHE_SIZE = 0
NUM_PROC_THREADS = 1
TIMESTAMP = NO
#---------------------------------------------------------------------------
# Build related configuration options
#---------------------------------------------------------------------------
EXTRACT_ALL = NO
EXTRACT_PRIVATE = NO
EXTRACT_PRIV_VIRTUAL = NO
EXTRACT_PACKAGE = NO
EXTRACT_STATIC = NO
EXTRACT_LOCAL_CLASSES = YES
EXTRACT_LOCAL_METHODS = NO
EXTRACT_ANON_NSPACES = NO
RESOLVE_UNNAMED_PARAMS = YES
HIDE_UNDOC_MEMBERS = NO
HIDE_UNDOC_CLASSES = NO
HIDE_UNDOC_NAMESPACES = YES
HIDE_FRIEND_COMPOUNDS = NO
HIDE_IN_BODY_DOCS = NO
INTERNAL_DOCS = NO
CASE_SENSE_NAMES = SYSTEM
HIDE_SCOPE_NAMES = NO
HIDE_COMPOUND_REFERENCE= NO
SHOW_HEADERFILE = YES
SHOW_INCLUDE_FILES = YES
SHOW_GROUPED_MEMB_INC = NO
FORCE_LOCAL_INCLUDES = NO
INLINE_INFO = YES
SORT_MEMBER_DOCS = YES
SORT_BRIEF_DOCS = NO
SORT_MEMBERS_CTORS_1ST = NO
SORT_GROUP_NAMES = NO
SORT_BY_SCOPE_NAME = NO
STRICT_PROTO_MATCHING = NO
GENERATE_TODOLIST = YES
GENERATE_TESTLIST = YES
GENERATE_BUGLIST = YES
GENERATE_DEPRECATEDLIST= YES
ENABLED_SECTIONS =
MAX_INITIALIZER_LINES = 30
SHOW_USED_FILES = YES
SHOW_FILES = YES
SHOW_NAMESPACES = YES
FILE_VERSION_FILTER =
LAYOUT_FILE =
CITE_BIB_FILES =
EXTERNAL_TOOL_PATH =
#---------------------------------------------------------------------------
# Configuration options related to warning and progress messages
#---------------------------------------------------------------------------
QUIET = NO
WARNINGS = YES
WARN_IF_UNDOCUMENTED = YES
WARN_IF_DOC_ERROR = YES
WARN_IF_INCOMPLETE_DOC = YES
WARN_NO_PARAMDOC = NO
WARN_IF_UNDOC_ENUM_VAL = NO
WARN_LAYOUT_FILE = YES
WARN_AS_ERROR = NO
WARN_FORMAT = "$file:$line: $text"
WARN_LINE_FORMAT = "at line $line of file $file"
WARN_LOGFILE =
#---------------------------------------------------------------------------
# Configuration options related to the input files
#---------------------------------------------------------------------------
INPUT =
INPUT_ENCODING = UTF-8
INPUT_FILE_ENCODING =
FILE_PATTERNS = *.c \
*.cc \
*.cxx \
*.cxxm \
*.cpp \
*.cppm \
*.ccm \
*.c++ \
*.c++m \
*.java \
*.ii \
*.ixx \
*.ipp \
*.i++ \
*.inl \
*.idl \
*.ddl \
*.odl \
*.h \
*.hh \
*.hxx \
*.hpp \
*.h++ \
*.l \
*.cs \
*.d \
*.php \
*.php4 \
*.php5 \
*.phtml \
*.inc \
*.m \
*.markdown \
*.md \
*.mm \
*.dox \
*.py \
*.pyw \
*.f90 \
*.f95 \
*.f03 \
*.f08 \
*.f18 \
*.f \
*.for \
*.vhd \
*.vhdl \
*.ucf \
*.qsf \
*.ice
RECURSIVE = NO
EXCLUDE =
EXCLUDE_SYMLINKS = NO
EXCLUDE_PATTERNS =
EXCLUDE_SYMBOLS =
EXAMPLE_PATH =
EXAMPLE_PATTERNS = *
EXAMPLE_RECURSIVE = NO
IMAGE_PATH =
INPUT_FILTER =
FILTER_PATTERNS =
FILTER_SOURCE_FILES = NO
FILTER_SOURCE_PATTERNS =
USE_MDFILE_AS_MAINPAGE =
IMPLICIT_DIR_DOCS = YES
FORTRAN_COMMENT_AFTER = 72
#---------------------------------------------------------------------------
# Configuration options related to source browsing
#---------------------------------------------------------------------------
SOURCE_BROWSER = NO
INLINE_SOURCES = NO
STRIP_CODE_COMMENTS = YES
REFERENCED_BY_RELATION = NO
REFERENCES_RELATION = NO
REFERENCES_LINK_SOURCE = YES
SOURCE_TOOLTIPS = YES
USE_HTAGS = NO
VERBATIM_HEADERS = YES
CLANG_ASSISTED_PARSING = NO
CLANG_ADD_INC_PATHS = YES
CLANG_OPTIONS =
CLANG_DATABASE_PATH =
#---------------------------------------------------------------------------
# Configuration options related to the alphabetical class index
#---------------------------------------------------------------------------
ALPHABETICAL_INDEX = YES
IGNORE_PREFIX =
#---------------------------------------------------------------------------
# Configuration options related to the HTML output
#---------------------------------------------------------------------------
GENERATE_HTML = YES
HTML_OUTPUT = html
HTML_FILE_EXTENSION = .html
HTML_HEADER =
HTML_FOOTER =
HTML_STYLESHEET =
HTML_EXTRA_STYLESHEET =
HTML_EXTRA_FILES =
HTML_COLORSTYLE = AUTO_LIGHT
HTML_COLORSTYLE_HUE = 220
HTML_COLORSTYLE_SAT = 100
HTML_COLORSTYLE_GAMMA = 80
HTML_DYNAMIC_MENUS = YES
HTML_DYNAMIC_SECTIONS = NO
HTML_CODE_FOLDING = YES
HTML_COPY_CLIPBOARD = YES
HTML_PROJECT_COOKIE =
HTML_INDEX_NUM_ENTRIES = 100
GENERATE_DOCSET = NO
DOCSET_FEEDNAME = "Doxygen generated docs"
DOCSET_FEEDURL =
DOCSET_BUNDLE_ID = org.doxygen.Project
DOCSET_PUBLISHER_ID = org.doxygen.Publisher
DOCSET_PUBLISHER_NAME = Publisher
GENERATE_HTMLHELP = NO
CHM_FILE =
HHC_LOCATION =
GENERATE_CHI = NO
CHM_INDEX_ENCODING =
BINARY_TOC = NO
TOC_EXPAND = NO
SITEMAP_URL =
GENERATE_QHP = NO
QCH_FILE =
QHP_NAMESPACE = org.doxygen.Project
QHP_VIRTUAL_FOLDER = doc
QHP_CUST_FILTER_NAME =
QHP_CUST_FILTER_ATTRS =
QHP_SECT_FILTER_ATTRS =
QHG_LOCATION =
GENERATE_ECLIPSEHELP = NO
ECLIPSE_DOC_ID = org.doxygen.Project
DISABLE_INDEX = NO
GENERATE_TREEVIEW = YES
PAGE_OUTLINE_PANEL = YES
FULL_SIDEBAR = NO
ENUM_VALUES_PER_LINE = 4
SHOW_ENUM_VALUES = NO
TREEVIEW_WIDTH = 250
EXT_LINKS_IN_WINDOW = NO
OBFUSCATE_EMAILS = YES
HTML_FORMULA_FORMAT = png
FORMULA_FONTSIZE = 10
FORMULA_MACROFILE =
USE_MATHJAX = NO
MATHJAX_VERSION = MathJax_2
MATHJAX_FORMAT = HTML-CSS
MATHJAX_RELPATH =
MATHJAX_EXTENSIONS =
MATHJAX_CODEFILE =
SEARCHENGINE = YES
SERVER_BASED_SEARCH = NO
EXTERNAL_SEARCH = NO
SEARCHENGINE_URL =
SEARCHDATA_FILE = searchdata.xml
EXTERNAL_SEARCH_ID =
EXTRA_SEARCH_MAPPINGS =
#---------------------------------------------------------------------------
# Configuration options related to the LaTeX output
#---------------------------------------------------------------------------
GENERATE_LATEX = YES
LATEX_OUTPUT = latex
LATEX_CMD_NAME =
MAKEINDEX_CMD_NAME = makeindex
LATEX_MAKEINDEX_CMD = makeindex
COMPACT_LATEX = NO
PAPER_TYPE = a4
EXTRA_PACKAGES =
LATEX_HEADER =
LATEX_FOOTER =
LATEX_EXTRA_STYLESHEET =
LATEX_EXTRA_FILES =
PDF_HYPERLINKS = YES
USE_PDFLATEX = YES
LATEX_BATCHMODE = NO
LATEX_HIDE_INDICES = NO
LATEX_BIB_STYLE = plainnat
LATEX_EMOJI_DIRECTORY =
#---------------------------------------------------------------------------
# Configuration options related to the RTF output
#---------------------------------------------------------------------------
GENERATE_RTF = NO
RTF_OUTPUT = rtf
COMPACT_RTF = NO
RTF_HYPERLINKS = NO
RTF_STYLESHEET_FILE =
RTF_EXTENSIONS_FILE =
RTF_EXTRA_FILES =
#---------------------------------------------------------------------------
# Configuration options related to the man page output
#---------------------------------------------------------------------------
GENERATE_MAN = NO
MAN_OUTPUT = man
MAN_EXTENSION = .3
MAN_SUBDIR =
MAN_LINKS = NO
#---------------------------------------------------------------------------
# Configuration options related to the XML output
#---------------------------------------------------------------------------
GENERATE_XML = NO
XML_OUTPUT = xml
XML_PROGRAMLISTING = YES
XML_NS_MEMB_FILE_SCOPE = NO
#---------------------------------------------------------------------------
# Configuration options related to the DOCBOOK output
#---------------------------------------------------------------------------
GENERATE_DOCBOOK = NO
DOCBOOK_OUTPUT = docbook
#---------------------------------------------------------------------------
# Configuration options for the AutoGen Definitions output
#---------------------------------------------------------------------------
GENERATE_AUTOGEN_DEF = NO
#---------------------------------------------------------------------------
# Configuration options related to Sqlite3 output
#---------------------------------------------------------------------------
GENERATE_SQLITE3 = NO
SQLITE3_OUTPUT = sqlite3
SQLITE3_RECREATE_DB = YES
#---------------------------------------------------------------------------
# Configuration options related to the Perl module output
#---------------------------------------------------------------------------
GENERATE_PERLMOD = NO
PERLMOD_LATEX = NO
PERLMOD_PRETTY = YES
PERLMOD_MAKEVAR_PREFIX =
#---------------------------------------------------------------------------
# Configuration options related to the preprocessor
#---------------------------------------------------------------------------
ENABLE_PREPROCESSING = YES
MACRO_EXPANSION = NO
EXPAND_ONLY_PREDEF = NO
SEARCH_INCLUDES = YES
INCLUDE_PATH =
INCLUDE_FILE_PATTERNS =
PREDEFINED =
EXPAND_AS_DEFINED =
SKIP_FUNCTION_MACROS = YES
#---------------------------------------------------------------------------
# Configuration options related to external references
#---------------------------------------------------------------------------
TAGFILES =
GENERATE_TAGFILE =
ALLEXTERNALS = NO
EXTERNAL_GROUPS = YES
EXTERNAL_PAGES = YES
#---------------------------------------------------------------------------
# Configuration options related to diagram generator tools
#---------------------------------------------------------------------------
HIDE_UNDOC_RELATIONS = YES
HAVE_DOT = NO
DOT_NUM_THREADS = 0
DOT_COMMON_ATTR = "fontname=Helvetica,fontsize=10"
DOT_EDGE_ATTR = "labelfontname=Helvetica,labelfontsize=10"
DOT_NODE_ATTR = "shape=box,height=0.2,width=0.4"
DOT_FONTPATH =
CLASS_GRAPH = YES
COLLABORATION_GRAPH = YES
GROUP_GRAPHS = YES
UML_LOOK = NO
UML_LIMIT_NUM_FIELDS = 10
UML_MAX_EDGE_LABELS = 10
DOT_UML_DETAILS = NO
DOT_WRAP_THRESHOLD = 17
TEMPLATE_RELATIONS = NO
INCLUDE_GRAPH = YES
INCLUDED_BY_GRAPH = YES
CALL_GRAPH = NO
CALLER_GRAPH = NO
GRAPHICAL_HIERARCHY = YES
DIRECTORY_GRAPH = YES
DIR_GRAPH_MAX_DEPTH = 1
DOT_IMAGE_FORMAT = png
INTERACTIVE_SVG = NO
DOT_PATH =
DOTFILE_DIRS =
DIA_PATH =
DIAFILE_DIRS =
PLANTUML_JAR_PATH =
PLANTUML_CFG_FILE =
PLANTUML_INCLUDE_PATH =
PLANTUMLFILE_DIRS =
DOT_GRAPH_MAX_NODES = 50
MAX_DOT_GRAPH_DEPTH = 0
DOT_MULTI_TARGETS = NO
GENERATE_LEGEND = YES
DOT_CLEANUP = YES
MSCGEN_TOOL =
MSCFILE_DIRS =
# ---- Project overrides (Python source in src/, output in docs/doxygen) ----
PROJECT_NAME = "Blackjack"
PROJECT_BRIEF = "Console Blackjack game against a computer dealer"
OUTPUT_DIRECTORY = docs/doxygen
INPUT = src README.md
FILE_PATTERNS = *.py *.md
RECURSIVE = YES
OPTIMIZE_OUTPUT_JAVA = YES
EXTRACT_ALL = YES
GENERATE_LATEX = NO
GENERATE_HTML = YES
WARN_IF_UNDOCUMENTED = YES
WARN_AS_ERROR = NO
USE_MDFILE_AS_MAINPAGE = README.md
+93 -1
View File
@@ -1,2 +1,94 @@
# 011-black-jack # Blackjack
A console Blackjack game against a computer dealer, played with emoji cards.
It is the day 11 capstone of Udemy's *100 Days of Code: The Complete Python Pro
Bootcamp*, built with a plan-first process (milestones and issues in `docs/`).
## House rules
- The deck is unlimited; cards are not removed when drawn. No jokers.
- Jack, Queen and King count 10. An ace counts 11 or 1.
- A two-card hand of ace + 10 is a blackjack.
- The computer is the dealer and draws while its score is below 17.
## Requirements
- Python 3.13 or newer
- No runtime dependencies
- Optional: [Doxygen](https://www.doxygen.nl/) to build the source documentation
- A terminal with UTF-8 and emoji support (for example Windows Terminal)
## Setup
Create and activate a local virtual environment, upgrade pip, then install the
project in editable mode with the development tools.
Windows (PowerShell):
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
Linux and macOS:
```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
Leave the environment with `deactivate`.
## Run
Start the game from the activated virtual environment:
```bash
python -m blackjack
```
## Test
```bash
python -m unittest discover -s tests
```
`pytest` also runs the same tests (`python -m pytest`).
## Lint and type check
```bash
ruff check .
ruff format --check .
mypy src
```
## Documentation
Generate the source documentation (Doxygen comments in `src/`) into
`docs/doxygen/`:
```bash
doxygen Doxyfile
```
Planning documents (business case, stakeholder analysis, project plan and
milestones) are in `docs/`.
## Project layout
```text
src/blackjack/ game package (constants.py, ...)
tests/ unit tests
docs/ planning documents and generated Doxygen output
pyproject.toml project configuration
Doxyfile Doxygen configuration
```
## License
See [LICENSE](LICENSE).
+2 -1
View File
@@ -9,7 +9,7 @@
## Version History ## Version History
| Date | Status | Author | Reviewer | Change | Commit | | Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 2026-10-04 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version | pending | | 2026-10-04 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version | [1d35410] |
--- ---
@@ -127,3 +127,4 @@ Proceed - the scope is small, the rules are fully specified by the assignment an
--- ---
[SA-001]: ./stakeholder-analysis.md [SA-001]: ./stakeholder-analysis.md
[1d35410]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/011-black-jack/commit/1d35410b8d29df6c4991a0b9aff24f8509d58a86
+2 -1
View File
@@ -9,7 +9,7 @@
## Version History ## Version History
| Date | Status | Author | Reviewer | Change | Commit | | Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 2026-10-04 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version | pending | | 2026-10-04 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version | [1d35410] |
--- ---
@@ -69,3 +69,4 @@ A repository with `src/`, `tests/`, `docs/`, a `pyproject.toml` (Python 3.13+, n
--- ---
[BC-001]: ../business-case.md [BC-001]: ../business-case.md
[1d35410]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/011-black-jack/commit/1d35410b8d29df6c4991a0b9aff24f8509d58a86
@@ -9,7 +9,7 @@
## Version History ## Version History
| Date | Status | Author | Reviewer | Change | Commit | | Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 2026-10-04 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version | pending | | 2026-10-04 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version | [1d35410] |
--- ---
@@ -72,3 +72,4 @@ A runnable console game (`python -m blackjack` from the activated venv) with emo
[BC-001]: ../business-case.md [BC-001]: ../business-case.md
[MIL-001]: ./mil-001-project-setup.md [MIL-001]: ./mil-001-project-setup.md
[1d35410]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/011-black-jack/commit/1d35410b8d29df6c4991a0b9aff24f8509d58a86
+2 -1
View File
@@ -9,7 +9,7 @@
## Version History ## Version History
| Date | Status | Author | Reviewer | Change | Commit | | Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 2026-10-04 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version | pending | | 2026-10-04 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version | [1d35410] |
--- ---
@@ -81,3 +81,4 @@ A No-Go on Gateway 1 moves every Gateway 2 date by the same number of days.
[MIL-002]: ./milestones/mil-002-game-implementation.md [MIL-002]: ./milestones/mil-002-game-implementation.md
[Milestone-34]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/011-black-jack/milestone/34 [Milestone-34]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/011-black-jack/milestone/34
[Milestone-35]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/011-black-jack/milestone/35 [Milestone-35]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/011-black-jack/milestone/35
[1d35410]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/011-black-jack/commit/1d35410b8d29df6c4991a0b9aff24f8509d58a86
+2 -1
View File
@@ -9,7 +9,7 @@
## Version History ## Version History
| Date | Status | Author | Reviewer | Change | Commit | | Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 2026-10-04 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version | pending | | 2026-10-04 | Accepted | Jens Tirsvad Nielsen | S01 | Initial version | [1d35410] |
--- ---
@@ -77,3 +77,4 @@ Pending review by S01.
--- ---
[BC-001]: ./business-case.md [BC-001]: ./business-case.md
[1d35410]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/011-black-jack/commit/1d35410b8d29df6c4991a0b9aff24f8509d58a86
+33
View File
@@ -0,0 +1,33 @@
[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"
[project]
name = "blackjack"
version = "0.1.0"
description = "Console Blackjack game against a computer dealer (Udemy 100 Days of Code, day 11)."
readme = "README.md"
requires-python = ">=3.13"
license = { file = "LICENSE" }
dependencies = []
[project.optional-dependencies]
dev = [
"mypy",
"pytest",
"ruff",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.ruff]
line-length = 88
src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
[tool.mypy]
strict = true
mypy_path = "src"
+6
View File
@@ -0,0 +1,6 @@
"""!
@file __init__.py
@brief Console Blackjack game against a computer dealer.
"""
__version__ = "0.1.0"
+21
View File
@@ -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()
+16
View File
@@ -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| _/ |
`------' |__/
"""
+73
View File
@@ -0,0 +1,73 @@
"""!
@file constants.py
@brief Constants for the Blackjack game; no magic numbers live in game code.
"""
## Value of an ace when it is counted as eleven.
ACE_HIGH: int = 11
## Value of an ace when it is counted as one.
ACE_LOW: int = 1
## The best possible score; a hand above it is bust.
BLACKJACK_SCORE: int = 21
## The dealer keeps drawing while its score is below this value.
DEALER_STAND_SCORE: int = 17
## Number of cards in the opening hand.
OPENING_HAND_SIZE: int = 2
## Score returned by the scoring function to mark a blackjack.
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]
## 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"
+61
View File
@@ -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]
+128
View File
@@ -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)
+78
View File
@@ -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
+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()
+38
View File
@@ -0,0 +1,38 @@
"""!
@file test_package.py
@brief Smoke tests: the package imports and its constants match the house rules.
"""
import unittest
import blackjack
from blackjack import constants
class TestPackage(unittest.TestCase):
"""!
@brief Checks that the package and its constants load.
"""
def test_package_has_version(self) -> None:
"""!
@brief The package exposes a version string.
"""
self.assertTrue(blackjack.__version__)
def test_deck_matches_house_rules(self) -> None:
"""!
@brief The deck is the 13-card list from the assignment.
"""
self.assertEqual(constants.CARDS, [11, 2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 10, 10])
def test_every_card_has_an_emoji_face(self) -> None:
"""!
@brief Every card in the deck has a rank to render.
"""
for card in constants.CARDS:
self.assertIn(card, constants.CARD_RANKS)
if __name__ == "__main__":
unittest.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()