diff --git a/pyproject.toml b/pyproject.toml index 925a26b..81809d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ where = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] -pythonpath = ["src"] +pythonpath = ["src", "."] [tool.ruff] line-length = 88 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..3360580 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,4 @@ +"""! +@file __init__.py +@brief Test package. +""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a2748db --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_machine.py b/tests/test_machine.py new file mode 100644 index 0000000..2bf7b3f --- /dev/null +++ b/tests/test_machine.py @@ -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 diff --git a/tests/test_resources.py b/tests/test_resources.py new file mode 100644 index 0000000..e95142d --- /dev/null +++ b/tests/test_resources.py @@ -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" diff --git a/tests/test_transactions.py b/tests/test_transactions.py new file mode 100644 index 0000000..0f57a9e --- /dev/null +++ b/tests/test_transactions.py @@ -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"