diff --git a/src/coffee_machine/constants.py b/src/coffee_machine/constants.py index a1da4c7..2882b13 100644 --- a/src/coffee_machine/constants.py +++ b/src/coffee_machine/constants.py @@ -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." diff --git a/src/coffee_machine/main.py b/src/coffee_machine/main.py index 0a0a95d..03b6aec 100644 --- a/src/coffee_machine/main.py +++ b/src/coffee_machine/main.py @@ -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) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index a69f001..ed50fa2 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -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()