Compare commits

...
Author SHA1 Message Date
Tirsvad 8a0e03a6ff Add tests and publish repository metadata for MIL-003
Add 19 pytest tests covering the report, resource check, coin
processing, refunds, change, profit, make_coffee and the main loop.
Add a tests package and put the project root on the pytest path.
Doxygen output is clean. The repository description and topics were set
on the git host.

Closes #13
Closes #14
Closes #15
Closes #16
Closes #17

Task: MIL-003#1
Task: MIL-003#2
Task: MIL-003#3
Task: MIL-003#4
Task: MIL-003#5
2026-10-06 19:56:08 +08:00
Tirsvad 9b5eff7163 Merge pull request 'MIL-002: coffee machine core' (#21) from mil-002-coffee-machine-core into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Failing after 3s
Reviewed-on: Tirsvad-Udemy-100_days_of_code/015-coffee_machine#21
2026-10-06 13:52:17 +02:00
Tirsvad d570489a33 Implement coffee machine core for MIL-002
Add the menu, coin values, starting resources and message texts to
constants.py. Implement report, is_resource_sufficient, process_coins,
is_transaction_successful, make_coffee and the main loop. Money is
counted in whole cents. The smoke test now feeds "off" so the loop
exits.

Closes #6
Closes #7
Closes #8
Closes #9
Closes #10
Closes #11
Closes #12

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
2026-10-06 19:49:49 +08:00
Tirsvad 0f26d8d91e Merge pull request 'MIL-001: README with setup, run and test instructions' (#20) from mil-001-readme into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Failing after 4s
Reviewed-on: Tirsvad-Udemy-100_days_of_code/015-coffee_machine#20
2026-10-06 13:45:59 +02:00
Tirsvad c2a8767fb0 Add README with setup, run, test and Doxygen instructions
Follow the Higher Lower README template: project description, local
.venv setup with pip upgrade, running the machine, tests and code
checks, source documentation, layout and license.

Closes #5
Task: MIL-001#5
2026-10-06 19:45:09 +08:00
Tirsvad 322efae5b4 Merge pull request 'MIL-001: project skeleton and tooling' (#19) from mil-001-project-setup into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 5s
Reviewed-on: Tirsvad-Udemy-100_days_of_code/015-coffee_machine#19
2026-10-04 18:31:21 +02:00
Tirsvad 572074ae73 Set up project skeleton and tooling for MIL-001
Add pyproject.toml (Python >=3.13, no runtime dependencies, pytest, ruff
and mypy as dev extras), a Doxyfile that scans src/, the
src/coffee_machine package with stub constants and main modules, and a
pytest smoke test. Ignore Doxygen output in .gitignore.

Closes #1
Closes #2
Closes #3
Closes #4
Refs #5

Task: MIL-001#1
Task: MIL-001#2
Task: MIL-001#3
Task: MIL-001#4
2026-10-05 00:30:18 +08:00
Tirsvad c3e225b049 Merge pull request 'Add planning baseline: BC, SA, PP and MIL-001 to MIL-003' (#18) from plan-baseline into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
Reviewed-on: Tirsvad-Udemy-100_days_of_code/015-coffee_machine#18
2026-10-04 18:26:38 +02:00
14 changed files with 614 additions and 1 deletions
+3
View File
@@ -174,3 +174,6 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
# Doxygen output
docs/doxygen/
+14
View File
@@ -0,0 +1,14 @@
# Doxygen configuration for the coffee machine. Run: doxygen Doxyfile
PROJECT_NAME = "Coffee Machine"
PROJECT_BRIEF = "Day 15 of Udemy's 100 Days of Code Python bootcamp"
OUTPUT_DIRECTORY = docs/doxygen
INPUT = src
FILE_PATTERNS = *.py
RECURSIVE = YES
OPTIMIZE_OUTPUT_JAVA = YES
EXTRACT_ALL = YES
JAVADOC_AUTOBRIEF = YES
GENERATE_HTML = YES
GENERATE_LATEX = NO
QUIET = YES
WARN_IF_UNDOCUMENTED = YES
+101 -1
View File
@@ -1,2 +1,102 @@
# 015-coffee_machine # Coffee Machine
A console game from Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*
(day 15). A virtual coffee machine serves espresso, latte and cappuccino. Pay with
US coins (quarters, dimes, nickels and pennies). The machine checks its water, milk
and coffee before it serves, refunds you if you pay too little and gives change if
you pay too much.
```
What would you like? (espresso/latte/cappuccino): latte
Please insert coins.
How many quarters?: 10
How many dimes?: 0
How many nickels?: 0
How many pennies?: 0
Here is your latte. Enjoy!
```
- Python 3.13 or newer, no runtime dependencies.
- Type `report` to see the water, milk, coffee and money in the machine, and `off`
to switch the machine off.
- The function names follow the assignment: `is_resource_sufficient`,
`process_coins`, `is_transaction_successful`, `make_coffee`.
- Money is counted in whole cents inside the program, so change is never off by a
rounding error.
## Requirements
- [Python](https://www.python.org/downloads/) 3.13 or newer
- Optional: [Doxygen](https://www.doxygen.nl/) to build the source documentation
## Set up
Create a local virtual environment in `.venv`, activate it, and upgrade `pip`.
Windows (PowerShell):
```powershell
python -m venv .venv
.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
```
Windows (Git Bash), Linux and macOS:
```bash
python -m venv .venv
source .venv/Scripts/activate # Linux/macOS: source .venv/bin/activate
python -m pip install --upgrade pip
```
The machine itself needs nothing more. To run the tests and the code checks, install
the development tools:
```bash
python -m pip install -e ".[dev]"
```
## Run the coffee machine
```bash
python -m coffee_machine
```
Type `espresso`, `latte` or `cappuccino` and press Enter, then enter the coins.
Type `report` for the machine's resources, or `off` to quit.
## Run the tests
```bash
python -m pytest
```
Check the code style and types:
```bash
python -m ruff check src tests
python -m ruff format --check src tests
python -m mypy src tests
```
## Build the source documentation
The source uses Doxygen comments. The HTML output goes to `docs/doxygen/html`.
```bash
doxygen Doxyfile
```
## Project layout
| Path | Content |
| --- | --- |
| `src/coffee_machine/` | The machine: `main.py` (the loop), `constants.py` (menu, coins, starting resources) |
| `tests/` | pytest tests |
| `docs/` | Planning and review documents (business case, plan, milestones) |
| `pyproject.toml` | Project configuration |
| `Doxyfile` | Doxygen configuration |
## License
GNU Affero General Public License v3.0. See [LICENSE](LICENSE).
+32
View File
@@ -0,0 +1,32 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "coffee-machine"
version = "0.1.0"
description = "Console coffee machine: Day 15 of Udemy's 100 Days of Code Python bootcamp."
readme = "README.md"
requires-python = ">=3.13"
license = { file = "LICENSE" }
dependencies = []
[project.optional-dependencies]
dev = ["pytest>=8", "ruff", "mypy"]
[project.scripts]
coffee-machine = "coffee_machine.main:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src", "."]
[tool.ruff]
line-length = 88
[tool.mypy]
strict = true
mypy_path = "src"
+4
View File
@@ -0,0 +1,4 @@
"""!
@file __init__.py
@brief Console coffee machine package.
"""
+8
View File
@@ -0,0 +1,8 @@
"""!
@file __main__.py
@brief Allow `python -m coffee_machine`.
"""
from coffee_machine.main import main
main()
+54
View File
@@ -0,0 +1,54 @@
"""!
@file constants.py
@brief Constants for the coffee machine: menu, coins, starting resources, texts.
Money is stored in whole cents so that sums and change are exact.
"""
from typing import TypedDict
class Drink(TypedDict):
"""!
@brief A menu entry: the ingredients it needs and its price.
"""
ingredients: dict[str, int]
cost: int
MENU: dict[str, Drink] = {
"espresso": {"ingredients": {"water": 50, "coffee": 18}, "cost": 150},
"latte": {
"ingredients": {"water": 200, "milk": 150, "coffee": 24},
"cost": 250,
},
"cappuccino": {
"ingredients": {"water": 250, "milk": 100, "coffee": 24},
"cost": 300,
},
}
## Value of each US coin in cents, in the order the machine asks for them.
COIN_VALUES: dict[str, int] = {
"quarters": 25,
"dimes": 10,
"nickels": 5,
"pennies": 1,
}
INITIAL_RESOURCES: dict[str, int] = {"water": 300, "milk": 200, "coffee": 100}
## Unit printed after each resource in the report.
RESOURCE_UNITS: dict[str, str] = {"water": "ml", "milk": "ml", "coffee": "g"}
CENTS_PER_DOLLAR = 100
COMMAND_REPORT = "report"
COMMAND_OFF = "off"
PROMPT_ORDER = "What would you like? (espresso/latte/cappuccino): "
MESSAGE_INSERT_COINS = "Please insert coins."
MESSAGE_NOT_ENOUGH_MONEY = "Sorry that's not enough money. Money refunded."
MESSAGE_UNKNOWN_DRINK = "Sorry, we do not serve that."
MESSAGE_INVALID_NUMBER = "Please enter a whole number of coins, zero or more."
+138
View File
@@ -0,0 +1,138 @@
"""!
@file main.py
@brief Entry point and logic of the coffee machine.
"""
from coffee_machine.constants import (
CENTS_PER_DOLLAR,
COIN_VALUES,
COMMAND_OFF,
COMMAND_REPORT,
INITIAL_RESOURCES,
MENU,
MESSAGE_INSERT_COINS,
MESSAGE_INVALID_NUMBER,
MESSAGE_NOT_ENOUGH_MONEY,
MESSAGE_UNKNOWN_DRINK,
PROMPT_ORDER,
RESOURCE_UNITS,
)
def format_money(cents: int) -> str:
"""!
@brief Format an amount in cents as dollars.
@param cents Amount in whole cents.
@return The amount such as "$2.50".
"""
return f"${cents / CENTS_PER_DOLLAR:.2f}"
def report(resources: dict[str, int], profit: int) -> None:
"""!
@brief Print the resources left in the machine and the money earned.
@param resources Remaining amount of each resource.
@param profit Money earned so far, in cents.
"""
for name, amount in resources.items():
print(f"{name.capitalize()}: {amount}{RESOURCE_UNITS[name]}")
print(f"Money: {format_money(profit)}")
def is_resource_sufficient(
order_ingredients: dict[str, int], resources: dict[str, int]
) -> bool:
"""!
@brief Check that the machine has enough of every ingredient for an order.
@param order_ingredients Amount of each ingredient the drink needs.
@param resources Remaining amount of each resource.
@return True if every ingredient is available, otherwise False after
printing the first missing one.
"""
for name, needed in order_ingredients.items():
if needed > resources[name]:
print(f"Sorry there is not enough {name}.")
return False
return True
def _ask_coin_count(coin_name: str) -> int:
"""!
@brief Ask how many coins of one kind are inserted, until the answer is valid.
@param coin_name Plural coin name, such as "quarters".
@return The number of coins, zero or more.
"""
while True:
answer = input(f"How many {coin_name}?: ")
try:
count = int(answer)
except ValueError:
count = -1
if count >= 0:
return count
print(MESSAGE_INVALID_NUMBER)
def process_coins() -> int:
"""!
@brief Ask for the coins and total their value.
@return The total inserted, in cents.
"""
print(MESSAGE_INSERT_COINS)
return sum(
_ask_coin_count(coin_name) * value for coin_name, value in COIN_VALUES.items()
)
def is_transaction_successful(money_received: int, drink_cost: int) -> bool:
"""!
@brief Check the payment, refund it if too small, otherwise give change.
@param money_received Money inserted, in cents.
@param drink_cost Price of the drink, in cents.
@return True if the payment covers the price, otherwise False.
"""
if money_received < drink_cost:
print(MESSAGE_NOT_ENOUGH_MONEY)
return False
change = money_received - drink_cost
if change > 0:
print(f"Here is {format_money(change)} in change.")
return True
def make_coffee(
drink_name: str, order_ingredients: dict[str, int], resources: dict[str, int]
) -> None:
"""!
@brief Use up the drink's ingredients and serve it.
@param drink_name Name of the drink.
@param order_ingredients Amount of each ingredient the drink needs.
@param resources Remaining amount of each resource; updated in place.
"""
for name, needed in order_ingredients.items():
resources[name] -= needed
print(f"Here is your {drink_name}. Enjoy!")
def main() -> None:
"""!
@brief Run the coffee machine until the user types "off".
"""
resources = dict(INITIAL_RESOURCES)
profit = 0
while True:
choice = input(PROMPT_ORDER).strip().lower()
if choice == COMMAND_OFF:
return
if choice == COMMAND_REPORT:
report(resources, profit)
elif choice in MENU:
drink = MENU[choice]
if not is_resource_sufficient(drink["ingredients"], resources):
continue
payment = process_coins()
if is_transaction_successful(payment, drink["cost"]):
profit += drink["cost"]
make_coffee(choice, drink["ingredients"], resources)
else:
print(MESSAGE_UNKNOWN_DRINK)
+4
View File
@@ -0,0 +1,4 @@
"""!
@file __init__.py
@brief Test package.
"""
+25
View File
@@ -0,0 +1,25 @@
"""!
@file conftest.py
@brief Shared pytest helpers.
"""
from collections.abc import Callable, Iterable
import pytest
ScriptInput = Callable[[Iterable[str]], None]
@pytest.fixture
def script_input(monkeypatch: pytest.MonkeyPatch) -> ScriptInput:
"""!
@brief Provide a function that feeds the given answers to `input`.
@param monkeypatch pytest's monkeypatch fixture.
@return A function taking the answers in the order they are asked.
"""
def feed(answers: Iterable[str]) -> None:
remaining = iter(answers)
monkeypatch.setattr("builtins.input", lambda _prompt: next(remaining))
return feed
+107
View File
@@ -0,0 +1,107 @@
"""!
@file test_machine.py
@brief Tests for making drinks and the main loop.
"""
import pytest
from coffee_machine.constants import MENU
from coffee_machine.main import main, make_coffee
from tests.conftest import ScriptInput
COINS_FOR_LATTE = ["10", "0", "0", "0"]
NO_COINS = ["0", "0", "0", "0"]
FULL_REPORT = "Water: 300ml\nMilk: 200ml\nCoffee: 100g\nMoney: $0.00"
def test_make_coffee_uses_up_ingredients(capsys: pytest.CaptureFixture[str]) -> None:
"""!
@brief The drink's ingredients are deducted and the drink is served.
"""
resources = {"water": 300, "milk": 200, "coffee": 100}
make_coffee("latte", MENU["latte"]["ingredients"], resources)
assert resources == {"water": 100, "milk": 50, "coffee": 76}
assert capsys.readouterr().out == "Here is your latte. Enjoy!\n"
def test_off_ends_the_machine(script_input: ScriptInput) -> None:
"""!
@brief Typing off returns from the loop.
"""
script_input(["off"])
main()
def test_report_shows_starting_resources(
script_input: ScriptInput, capsys: pytest.CaptureFixture[str]
) -> None:
"""!
@brief A fresh machine reports full resources and no money.
"""
script_input(["report", "off"])
main()
assert FULL_REPORT in capsys.readouterr().out
def test_unknown_input_is_rejected(
script_input: ScriptInput, capsys: pytest.CaptureFixture[str]
) -> None:
"""!
@brief Anything that is not a drink or command prints a message.
"""
script_input(["tea", "off"])
main()
assert "Sorry, we do not serve that." in capsys.readouterr().out
def test_sale_updates_resources_and_profit(
script_input: ScriptInput, capsys: pytest.CaptureFixture[str]
) -> None:
"""!
@brief A paid drink is served, resources drop and profit rises by the price.
"""
script_input(["latte", *COINS_FOR_LATTE, "report", "off"])
main()
out = capsys.readouterr().out
assert "Here is your latte. Enjoy!" in out
assert "Water: 100ml\nMilk: 50ml\nCoffee: 76g\nMoney: $2.50" in out
def test_refund_changes_nothing(
script_input: ScriptInput, capsys: pytest.CaptureFixture[str]
) -> None:
"""!
@brief An underpaid order is refunded: no drink, no profit.
"""
script_input(["espresso", *NO_COINS, "report", "off"])
main()
out = capsys.readouterr().out
assert "Money refunded." in out
assert "Enjoy" not in out
assert FULL_REPORT in out
def test_overpayment_adds_only_price_to_profit(
script_input: ScriptInput, capsys: pytest.CaptureFixture[str]
) -> None:
"""!
@brief Change is returned and only the price is counted as profit.
"""
script_input(["espresso", "7", "0", "0", "0", "report", "off"])
main()
out = capsys.readouterr().out
assert "Here is $0.25 in change." in out
assert "Money: $1.50" in out
def test_order_refused_when_resources_run_out(
script_input: ScriptInput, capsys: pytest.CaptureFixture[str]
) -> None:
"""!
@brief After the water is used up, the next order is refused before payment.
"""
script_input(["latte", *COINS_FOR_LATTE, "latte", "off"])
main()
out = capsys.readouterr().out
assert out.count("Please insert coins.") == 1
assert "Sorry there is not enough water." in out
+52
View File
@@ -0,0 +1,52 @@
"""!
@file test_resources.py
@brief Tests for the report and the resource check.
"""
import pytest
from coffee_machine.constants import INITIAL_RESOURCES, MENU
from coffee_machine.main import format_money, is_resource_sufficient, report
def test_format_money_shows_two_decimals() -> None:
"""!
@brief Cents are shown as dollars with two decimals.
"""
assert format_money(250) == "$2.50"
assert format_money(0) == "$0.00"
def test_report_prints_resources_and_money(capsys: pytest.CaptureFixture[str]) -> None:
"""!
@brief The report lists each resource with its unit, then the money.
"""
report({"water": 300, "milk": 200, "coffee": 100}, 250)
assert capsys.readouterr().out == (
"Water: 300ml\nMilk: 200ml\nCoffee: 100g\nMoney: $2.50\n"
)
def test_is_resource_sufficient_when_enough() -> None:
"""!
@brief An order is accepted when every ingredient is available.
"""
assert is_resource_sufficient(MENU["latte"]["ingredients"], INITIAL_RESOURCES)
def test_is_resource_sufficient_when_exactly_enough() -> None:
"""!
@brief Using up an ingredient completely is allowed.
"""
assert is_resource_sufficient({"water": 300}, {"water": 300})
def test_is_resource_sufficient_names_missing_resource(
capsys: pytest.CaptureFixture[str],
) -> None:
"""!
@brief A short ingredient is refused and named.
"""
resources = {"water": 300, "milk": 100, "coffee": 100}
assert not is_resource_sufficient(MENU["latte"]["ingredients"], resources)
assert capsys.readouterr().out == "Sorry there is not enough milk.\n"
+16
View File
@@ -0,0 +1,16 @@
"""!
@file test_smoke.py
@brief Smoke test that the package imports.
"""
import pytest
from coffee_machine import main as main_module
def test_main_is_callable(monkeypatch: pytest.MonkeyPatch) -> None:
"""!
@brief The entry point starts and stops on "off".
"""
monkeypatch.setattr("builtins.input", lambda _prompt: "off")
main_module.main()
+56
View File
@@ -0,0 +1,56 @@
"""!
@file test_transactions.py
@brief Tests for coin processing and payment.
"""
import pytest
from coffee_machine.main import is_transaction_successful, process_coins
from tests.conftest import ScriptInput
def test_process_coins_totals_in_cents(script_input: ScriptInput) -> None:
"""!
@brief Quarters, dimes, nickels and pennies add up to the right total.
"""
script_input(["4", "3", "2", "1"])
assert process_coins() == 4 * 25 + 3 * 10 + 2 * 5 + 1
def test_process_coins_asks_again_on_invalid_count(
script_input: ScriptInput, capsys: pytest.CaptureFixture[str]
) -> None:
"""!
@brief Text and negative counts are rejected and asked again.
"""
script_input(["abc", "-1", "2", "0", "0", "0"])
assert process_coins() == 50
assert capsys.readouterr().out.count("Please enter a whole number") == 2
def test_transaction_refused_when_too_little(
capsys: pytest.CaptureFixture[str],
) -> None:
"""!
@brief Too little money is refunded with the refund message.
"""
assert not is_transaction_successful(100, 150)
assert "Sorry that's not enough money. Money refunded." in capsys.readouterr().out
def test_transaction_accepted_with_exact_payment(
capsys: pytest.CaptureFixture[str],
) -> None:
"""!
@brief Exact payment succeeds and gives no change.
"""
assert is_transaction_successful(150, 150)
assert "change" not in capsys.readouterr().out
def test_transaction_gives_change(capsys: pytest.CaptureFixture[str]) -> None:
"""!
@brief Overpaying succeeds and returns the difference.
"""
assert is_transaction_successful(175, 150)
assert capsys.readouterr().out == "Here is $0.25 in change.\n"