MIL-002: coffee machine core #21

Merged
Tirsvad merged 1 commits from mil-002-coffee-machine-core into main 2026-10-06 13:52:18 +02:00
3 changed files with 185 additions and 8 deletions
Showing only changes of commit d570489a33 - Show all commits
+50 -2
View File
@@ -1,6 +1,54 @@
"""!
@file constants.py
@brief Constants for the coffee machine: menu, coins and starting resources.
@brief Constants for the coffee machine: menu, coins, starting resources, texts.
The values are added in MIL-002 ("Define constants").
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."
+130 -4
View File
@@ -1,12 +1,138 @@
"""!
@file main.py
@brief Entry point of the coffee machine.
@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.
The ordering loop is added in MIL-002 ("Implement main loop").
@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)
+5 -2
View File
@@ -3,11 +3,14 @@
@brief Smoke test that the package imports.
"""
import pytest
from coffee_machine import main as main_module
def test_main_is_callable() -> None:
def test_main_is_callable(monkeypatch: pytest.MonkeyPatch) -> None:
"""!
@brief The entry point runs without error.
@brief The entry point starts and stops on "off".
"""
monkeypatch.setattr("builtins.input", lambda _prompt: "off")
main_module.main()