Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a0e03a6ff | ||
|
|
9b5eff7163 | ||
|
|
d570489a33 | ||
|
|
0f26d8d91e | ||
|
|
c2a8767fb0 | ||
|
|
322efae5b4 |
@@ -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).
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ where = ["src"]
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
pythonpath = ["src"]
|
pythonpath = ["src", "."]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 88
|
line-length = 88
|
||||||
|
|||||||
@@ -1,6 +1,54 @@
|
|||||||
"""!
|
"""!
|
||||||
@file constants.py
|
@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
@@ -1,12 +1,138 @@
|
|||||||
"""!
|
"""!
|
||||||
@file main.py
|
@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:
|
def main() -> None:
|
||||||
"""!
|
"""!
|
||||||
@brief Run the coffee machine.
|
@brief Run the coffee machine until the user types "off".
|
||||||
|
|
||||||
The ordering loop is added in MIL-002 ("Implement main loop").
|
|
||||||
"""
|
"""
|
||||||
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""!
|
||||||
|
@file __init__.py
|
||||||
|
@brief Test package.
|
||||||
|
"""
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
+5
-2
@@ -3,11 +3,14 @@
|
|||||||
@brief Smoke test that the package imports.
|
@brief Smoke test that the package imports.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from coffee_machine import main as main_module
|
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()
|
main_module.main()
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user