MIL-003: tests, Doxygen check and repository metadata #22

Merged
Tirsvad merged 1 commits from mil-003-quality-and-publication into main 2026-10-06 13:57:12 +02:00
6 changed files with 245 additions and 1 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
pythonpath = ["src", "."]
[tool.ruff]
line-length = 88
+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"
+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"