From 2b6b3bee46e99d487792c79f722a79f9c921304d Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 7 Oct 2026 10:49:35 +0800 Subject: [PATCH 1/2] Implement coffee machine program and tests (MIL-002) Add the Menu, MenuItem, CoffeeMaker and MoneyMachine classes with the assignment's method names, and a main loop that supports drink names, report and off. Resources are checked before payment, and payment before the drink is made. Move drink prices, ingredient amounts and all message text into constants.py. Add 25 pytest tests for every class and the main loop, and record the review against QC-PY-001 as RC-007. Task: MIL-002#1 Task: MIL-002#2 Task: MIL-002#3 Task: MIL-002#4 Task: MIL-002#5 Task: MIL-002#6 Closes #6 Closes #7 Closes #8 Closes #9 Closes #10 Closes #11 --- docs/artifact-registry.md | 2 +- .../sqa/reviews/rc-007-source-code-mil-002.md | 55 +++++++++++ src/coffee_machine/__main__.py | 7 ++ src/coffee_machine/coffee_maker.py | 44 +++++++++ src/coffee_machine/constants.py | 36 ++++++-- src/coffee_machine/main.py | 39 ++++++++ src/coffee_machine/menu.py | 49 ++++++++++ src/coffee_machine/money_machine.py | 59 ++++++++++++ tests/__init__.py | 0 tests/conftest.py | 20 ++++ tests/test_coffee_maker.py | 68 ++++++++++++++ tests/test_constants.py | 12 ++- tests/test_main.py | 91 +++++++++++++++++++ tests/test_menu.py | 28 ++++++ tests/test_money_machine.py | 62 +++++++++++++ 15 files changed, 561 insertions(+), 11 deletions(-) create mode 100644 docs/sqa/reviews/rc-007-source-code-mil-002.md create mode 100644 src/coffee_machine/__main__.py create mode 100644 src/coffee_machine/coffee_maker.py create mode 100644 src/coffee_machine/main.py create mode 100644 src/coffee_machine/menu.py create mode 100644 src/coffee_machine/money_machine.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_coffee_maker.py create mode 100644 tests/test_main.py create mode 100644 tests/test_menu.py create mode 100644 tests/test_money_machine.py diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index ebbcb56..2f541dc 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -15,7 +15,7 @@ document of a type. `Primary File` may contain a glob (e.g. | SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 | | PP | Project Plan | docs/project-plan.md | 002 | | MIL | Milestone / Gateway | docs/milestones/mil-*.md | 004 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 007 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 008 | | TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 | ## Languages diff --git a/docs/sqa/reviews/rc-007-source-code-mil-002.md b/docs/sqa/reviews/rc-007-source-code-mil-002.md new file mode 100644 index 0000000..5c8d0ea --- /dev/null +++ b/docs/sqa/reviews/rc-007-source-code-mil-002.md @@ -0,0 +1,55 @@ +# Review: Source code of MIL-002 + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-007 | +| CrossReference | [MIL-002], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-07 | Proposed | Jens Tirsvad Nielsen | S01 | Review record | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-002] (the source code under `src/coffee_machine/` and `tests/` that delivers it) +- Checklist used: [QC-PY-001] +- Scope: full review +- Language and domain: n/a (technical type, source code) +- Language reviewer: none + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing | Pass | `coffee_machine`, `CoffeeMaker`, `is_resource_sufficient`, `WATER`; checked by ruff rule set `N`. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations | Pass | Assignment names kept (`get_items`, `find_drink`, `make_payment`, `make_coffee`); no abbreviations. | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff format` and `ruff check` clean; no `noqa` or `type: ignore` in the code. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy --strict` passes on `src` and `tests`. | +| 5 | No bare `except:`, no swallowed exceptions | Pass | No `try`/`except` in the code. Invalid coin counts are handled by validation, not exceptions. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | None found; `conftest.py` uses `_prompt=""`, an immutable default. | +| 7 | Files, locks and connections are managed with context managers | N-A | The program opens no files, locks or connections. | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | All public items have Doxygen docstrings; Doxygen builds without warnings. | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | `print` is the program's user interface (the assignment's required prompts and messages), not logging. No secrets or personal data. The code never reads `.env`. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No DCD exists: the course supplies the design (see PP-001 Open Issues). The classes follow the assignment's `Menu`, `MenuItem`, `CoffeeMaker`, `MoneyMachine`. | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 25 tests cover each class and the main loop; `input` is replaced with a fixture; no order or network dependence. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` clean; no `Any`. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | No runtime dependencies. Dev tools have lower bounds only, which suits a learning project. | + +## Overall Verdict + +Go — all mandatory criteria pass or are not applicable with a stated reason. Verified by running `ruff format`, `ruff check`, `mypy --strict`, `pytest` (25 passed) and `doxygen Doxyfile` (no warnings), and by a manual run of the program. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| Decide whether a DCD or use case is wanted for the program (criterion 10) | S01 | 2026-10-14 | + +--- + +[MIL-002]: ../../milestones/mil-002-core.md +[QC-PY-001]: ../../../framework/qc/qc-programming-python.md diff --git a/src/coffee_machine/__main__.py b/src/coffee_machine/__main__.py new file mode 100644 index 0000000..3fd8a50 --- /dev/null +++ b/src/coffee_machine/__main__.py @@ -0,0 +1,7 @@ +"""! @file +@brief Allows `python -m coffee_machine`. +""" + +from coffee_machine.main import main + +main() diff --git a/src/coffee_machine/coffee_maker.py b/src/coffee_machine/coffee_maker.py new file mode 100644 index 0000000..1581dba --- /dev/null +++ b/src/coffee_machine/coffee_maker.py @@ -0,0 +1,44 @@ +"""! @file +@brief The part of the machine that holds ingredients and makes drinks. +""" + +from coffee_machine import constants +from coffee_machine.menu import MenuItem + + +class CoffeeMaker: + """! @brief Holds the resources and makes the drinks.""" + + def __init__(self) -> None: + """! @brief Fill the machine with the initial resources.""" + self.resources: dict[str, int] = dict(constants.INITIAL_RESOURCES) + + def report(self) -> None: + """! @brief Print the amount left of each resource.""" + for ingredient, amount in self.resources.items(): + unit = constants.RESOURCE_UNITS[ingredient] + print(f"{ingredient.capitalize()}: {amount}{unit}") + + def is_resource_sufficient(self, drink: MenuItem) -> bool: + """! @brief Check that every ingredient of the drink is available. + + Prints a message for each ingredient that is short. + + @param drink The drink the user ordered. + @return True when the machine can make the drink. + """ + is_sufficient = True + for ingredient, required in drink.ingredients.items(): + if required > self.resources[ingredient]: + print(constants.MSG_INSUFFICIENT_RESOURCE.format(ingredient=ingredient)) + is_sufficient = False + return is_sufficient + + def make_coffee(self, order: MenuItem) -> None: + """! @brief Deduct the ingredients and serve the drink. + + @param order The drink to make; resources must already be sufficient. + """ + for ingredient, required in order.ingredients.items(): + self.resources[ingredient] -= required + print(constants.MSG_ENJOY.format(drink=order.name)) diff --git a/src/coffee_machine/constants.py b/src/coffee_machine/constants.py index b6408a0..562907b 100644 --- a/src/coffee_machine/constants.py +++ b/src/coffee_machine/constants.py @@ -1,5 +1,5 @@ """! @file -@brief Constants for the coffee machine: menu, resources, coins and commands. +@brief Constants for the coffee machine: menu, resources, coins, commands, messages. """ from typing import Final @@ -11,11 +11,18 @@ MILK: Final = "milk" COFFEE: Final = "coffee" ## @} -## @brief Cost in dollars and ingredient amounts per drink. -MENU: Final[dict[str, dict[str, object]]] = { - "espresso": {"cost": 1.5, "ingredients": {WATER: 50, MILK: 0, COFFEE: 18}}, - "latte": {"cost": 2.5, "ingredients": {WATER: 200, MILK: 150, COFFEE: 24}}, - "cappuccino": {"cost": 3.0, "ingredients": {WATER: 250, MILK: 100, COFFEE: 24}}, +## @brief Price in dollars of each drink. +DRINK_COSTS: Final[dict[str, float]] = { + "espresso": 1.5, + "latte": 2.5, + "cappuccino": 3.0, +} + +## @brief Amount of each ingredient needed for one drink. +DRINK_INGREDIENTS: Final[dict[str, dict[str, int]]] = { + "espresso": {WATER: 50, MILK: 0, COFFEE: 18}, + "latte": {WATER: 200, MILK: 150, COFFEE: 24}, + "cappuccino": {WATER: 250, MILK: 100, COFFEE: 24}, } ## @brief Amount of each ingredient the machine starts with. @@ -38,8 +45,25 @@ INITIAL_PROFIT: Final = 0.0 ## @brief Currency symbol shown in messages. CURRENCY: Final = "$" +## @brief Number of decimals of an amount of money. +MONEY_DECIMALS: Final = 2 + ## @name Commands at the prompt ## @{ COMMAND_OFF: Final = "off" COMMAND_REPORT: Final = "report" ## @} + +## @name Messages +## @{ +PROMPT_TEMPLATE: Final = "What would you like? ({items}): " +MENU_SEPARATOR: Final = "/" +MSG_UNAVAILABLE: Final = "Sorry that item is not available." +MSG_INSUFFICIENT_RESOURCE: Final = "Sorry there is not enough {ingredient}." +MSG_INSERT_COINS: Final = "Please insert coins." +MSG_COIN_PROMPT: Final = "How many {coin}?: " +MSG_INVALID_COUNT: Final = "Please enter a whole number of 0 or more." +MSG_NOT_ENOUGH_MONEY: Final = "Sorry that's not enough money. Money refunded." +MSG_CHANGE: Final = "Here is {currency}{change:.2f} in change." +MSG_ENJOY: Final = "Here is your {drink}. Enjoy!" +## @} diff --git a/src/coffee_machine/main.py b/src/coffee_machine/main.py new file mode 100644 index 0000000..8770800 --- /dev/null +++ b/src/coffee_machine/main.py @@ -0,0 +1,39 @@ +"""! @file +@brief Entry point: keeps the coffee machine running until it is turned off. +""" + +from coffee_machine import constants +from coffee_machine.coffee_maker import CoffeeMaker +from coffee_machine.menu import Menu +from coffee_machine.money_machine import MoneyMachine + + +def main() -> None: + """! @brief Run the machine until the user types the off command. + + Asks for a drink, checks the resources, takes payment and then makes it. + """ + menu = Menu() + coffee_maker = CoffeeMaker() + money_machine = MoneyMachine() + + while True: + choice = input(constants.PROMPT_TEMPLATE.format(items=menu.get_items())) + choice = choice.strip().lower() + if choice == constants.COMMAND_OFF: + break + if choice == constants.COMMAND_REPORT: + coffee_maker.report() + money_machine.report() + continue + drink = menu.find_drink(choice) + if ( + drink is not None + and coffee_maker.is_resource_sufficient(drink) + and money_machine.make_payment(drink.cost) + ): + coffee_maker.make_coffee(drink) + + +if __name__ == "__main__": + main() diff --git a/src/coffee_machine/menu.py b/src/coffee_machine/menu.py new file mode 100644 index 0000000..9cde41d --- /dev/null +++ b/src/coffee_machine/menu.py @@ -0,0 +1,49 @@ +"""! @file +@brief Menu of drinks the coffee machine can make. +""" + +from collections.abc import Mapping +from dataclasses import dataclass + +from coffee_machine import constants + + +@dataclass(frozen=True) +class MenuItem: + """! @brief A drink with its price and the ingredients it needs.""" + + name: str + cost: float + ingredients: Mapping[str, int] + + +class Menu: + """! @brief The list of drinks that can be ordered.""" + + def __init__(self) -> None: + """! @brief Build the menu from the drinks in constants.py.""" + self._items: list[MenuItem] = [ + MenuItem(name, cost, constants.DRINK_INGREDIENTS[name]) + for name, cost in constants.DRINK_COSTS.items() + ] + + def get_items(self) -> str: + """! @brief Return the drink names for the prompt, e.g. "espresso/latte". + + @return The drink names joined by the menu separator. + """ + return constants.MENU_SEPARATOR.join(item.name for item in self._items) + + def find_drink(self, order_name: str) -> MenuItem | None: + """! @brief Look up a drink by name. + + Prints a message when the drink does not exist. + + @param order_name The name the user typed. + @return The matching item, or None when the drink is not on the menu. + """ + for item in self._items: + if item.name == order_name: + return item + print(constants.MSG_UNAVAILABLE) + return None diff --git a/src/coffee_machine/money_machine.py b/src/coffee_machine/money_machine.py new file mode 100644 index 0000000..c4dfb4f --- /dev/null +++ b/src/coffee_machine/money_machine.py @@ -0,0 +1,59 @@ +"""! @file +@brief The part of the machine that takes coins and keeps the profit. +""" + +from coffee_machine import constants + + +class MoneyMachine: + """! @brief Processes coins, gives change and keeps the profit.""" + + def __init__(self) -> None: + """! @brief Start with the initial profit and no money received.""" + self.profit: float = constants.INITIAL_PROFIT + self.money_received: float = 0.0 + + def report(self) -> None: + """! @brief Print the money in the machine.""" + print(f"Money: {constants.CURRENCY}{self.profit:.{constants.MONEY_DECIMALS}f}") + + def process_coins(self) -> float: + """! @brief Ask for each coin type and add up the value. + + @return The total value of the coins inserted, in dollars. + """ + print(constants.MSG_INSERT_COINS) + total = 0.0 + for coin, value in constants.COIN_VALUES.items(): + total += self._ask_count(coin) * value + self.money_received = round(total, constants.MONEY_DECIMALS) + return self.money_received + + def make_payment(self, cost: float) -> bool: + """! @brief Take coins for a drink, give change or refund. + + @param cost The price of the drink in dollars. + @return True when enough money was inserted. + """ + self.process_coins() + if self.money_received < cost: + print(constants.MSG_NOT_ENOUGH_MONEY) + self.money_received = 0.0 + return False + change = round(self.money_received - cost, constants.MONEY_DECIMALS) + if change > 0: + print( + constants.MSG_CHANGE.format(currency=constants.CURRENCY, change=change) + ) + self.profit += cost + self.money_received = 0.0 + return True + + @staticmethod + def _ask_count(coin: str) -> int: + """! @brief Ask how many coins of one type until a valid count is given.""" + while True: + answer = input(constants.MSG_COIN_PROMPT.format(coin=coin)) + if answer.strip().isdigit(): + return int(answer) + print(constants.MSG_INVALID_COUNT) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..efdfae7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +"""! @file +@brief Shared test helpers. +""" + +from collections.abc import Callable, Iterable + +import pytest + +InputFeeder = Callable[[Iterable[str]], None] + + +@pytest.fixture +def feed_input(monkeypatch: pytest.MonkeyPatch) -> InputFeeder: + """! @brief Return a function that makes input() answer from a list.""" + + def feed(answers: Iterable[str]) -> None: + iterator = iter(answers) + monkeypatch.setattr("builtins.input", lambda _prompt="": next(iterator)) + + return feed diff --git a/tests/test_coffee_maker.py b/tests/test_coffee_maker.py new file mode 100644 index 0000000..8fadb34 --- /dev/null +++ b/tests/test_coffee_maker.py @@ -0,0 +1,68 @@ +"""! @file +@brief Tests for CoffeeMaker. +""" + +import pytest + +from coffee_machine import constants +from coffee_machine.coffee_maker import CoffeeMaker +from coffee_machine.menu import Menu, MenuItem + + +def _drink(name: str) -> MenuItem: + drink = Menu().find_drink(name) + assert drink is not None + return drink + + +def test_report_prints_each_resource_with_unit( + capsys: pytest.CaptureFixture[str], +) -> None: + CoffeeMaker().report() + + assert capsys.readouterr().out.splitlines() == [ + "Water: 300ml", + "Milk: 200ml", + "Coffee: 100g", + ] + + +def test_is_resource_sufficient_when_enough() -> None: + assert CoffeeMaker().is_resource_sufficient(_drink("latte")) + + +def test_is_resource_sufficient_when_exactly_enough() -> None: + maker = CoffeeMaker() + maker.resources.update( + {constants.WATER: 50, constants.MILK: 0, constants.COFFEE: 18} + ) + + assert maker.is_resource_sufficient(_drink("espresso")) + + +def test_is_resource_sufficient_when_short_names_each_missing_ingredient( + capsys: pytest.CaptureFixture[str], +) -> None: + maker = CoffeeMaker() + maker.resources.update({constants.WATER: 10, constants.MILK: 10}) + + assert not maker.is_resource_sufficient(_drink("latte")) + out = capsys.readouterr().out + assert "not enough water" in out + assert "not enough milk" in out + assert "not enough coffee" not in out + + +def test_make_coffee_deducts_ingredients_and_serves( + capsys: pytest.CaptureFixture[str], +) -> None: + maker = CoffeeMaker() + + maker.make_coffee(_drink("latte")) + + assert maker.resources == { + constants.WATER: 100, + constants.MILK: 50, + constants.COFFEE: 76, + } + assert "latte" in capsys.readouterr().out diff --git a/tests/test_constants.py b/tests/test_constants.py index b6e46ca..4763f7f 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -5,10 +5,14 @@ from coffee_machine import constants +def test_every_drink_has_a_cost_and_ingredients() -> None: + assert set(constants.DRINK_COSTS) == set(constants.DRINK_INGREDIENTS) + + def test_every_drink_uses_known_ingredients() -> None: known = set(constants.INITIAL_RESOURCES) - for drink in constants.MENU.values(): - assert set(drink["ingredients"]) <= known # type: ignore[call-overload] + for ingredients in constants.DRINK_INGREDIENTS.values(): + assert set(ingredients) <= known def test_every_resource_has_a_unit() -> None: @@ -16,5 +20,5 @@ def test_every_resource_has_a_unit() -> None: def test_commands_are_not_drink_names() -> None: - assert constants.COMMAND_OFF not in constants.MENU - assert constants.COMMAND_REPORT not in constants.MENU + assert constants.COMMAND_OFF not in constants.DRINK_COSTS + assert constants.COMMAND_REPORT not in constants.DRINK_COSTS diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..c8f9efa --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,91 @@ +"""! @file +@brief Tests for the main loop. +""" + +import pytest + +from coffee_machine.main import main +from tests.conftest import InputFeeder + +PAY_150 = ["6", "0", "0", "0"] +PAY_250 = ["10", "0", "0", "0"] +PAY_NOTHING = ["0", "0", "0", "0"] + + +def test_off_stops_the_loop(feed_input: InputFeeder) -> None: + feed_input(["off"]) + + main() + + +def test_report_shows_resources_and_money( + feed_input: InputFeeder, capsys: pytest.CaptureFixture[str] +) -> None: + feed_input(["report", "off"]) + + main() + + out = capsys.readouterr().out + assert "Water: 300ml" in out + assert "Money: $0.00" in out + + +def test_successful_order_makes_drink_and_updates_report( + feed_input: InputFeeder, capsys: pytest.CaptureFixture[str] +) -> None: + feed_input(["espresso", *PAY_150, "report", "off"]) + + main() + + out = capsys.readouterr().out + assert "Here is your espresso" in out + assert "Water: 250ml" in out + assert "Money: $1.50" in out + + +def test_choice_is_case_insensitive( + feed_input: InputFeeder, capsys: pytest.CaptureFixture[str] +) -> None: + feed_input([" Espresso ", *PAY_150, "off"]) + + main() + + assert "Here is your espresso" in capsys.readouterr().out + + +def test_unknown_drink_asks_again_without_payment( + feed_input: InputFeeder, capsys: pytest.CaptureFixture[str] +) -> None: + feed_input(["tea", "off"]) + + main() + + out = capsys.readouterr().out + assert "not available" in out + assert "insert coins" not in out + + +def test_insufficient_resources_stop_before_payment( + feed_input: InputFeeder, capsys: pytest.CaptureFixture[str] +) -> None: + # The second latte needs 150 ml milk but only 50 ml is left. + feed_input(["latte", *PAY_250, "latte", "off"]) + + main() + + out = capsys.readouterr().out + assert out.count("insert coins") == 1 + assert "not enough milk" in out + + +def test_short_payment_is_refunded_and_no_drink_is_made( + feed_input: InputFeeder, capsys: pytest.CaptureFixture[str] +) -> None: + feed_input(["espresso", *PAY_NOTHING, "report", "off"]) + + main() + + out = capsys.readouterr().out + assert "Money refunded" in out + assert "Enjoy" not in out + assert "Water: 300ml" in out diff --git a/tests/test_menu.py b/tests/test_menu.py new file mode 100644 index 0000000..7a0d0a3 --- /dev/null +++ b/tests/test_menu.py @@ -0,0 +1,28 @@ +"""! @file +@brief Tests for Menu and MenuItem. +""" + +import pytest + +from coffee_machine import constants +from coffee_machine.menu import Menu + + +def test_get_items_lists_all_drinks_separated() -> None: + assert Menu().get_items() == "espresso/latte/cappuccino" + + +def test_find_drink_returns_matching_item() -> None: + drink = Menu().find_drink("latte") + + assert drink is not None + assert drink.name == "latte" + assert drink.cost == constants.DRINK_COSTS["latte"] + assert drink.ingredients == constants.DRINK_INGREDIENTS["latte"] + + +def test_find_drink_when_unknown_returns_none_and_prints_message( + capsys: pytest.CaptureFixture[str], +) -> None: + assert Menu().find_drink("tea") is None + assert constants.MSG_UNAVAILABLE in capsys.readouterr().out diff --git a/tests/test_money_machine.py b/tests/test_money_machine.py new file mode 100644 index 0000000..0cfd6bb --- /dev/null +++ b/tests/test_money_machine.py @@ -0,0 +1,62 @@ +"""! @file +@brief Tests for MoneyMachine. +""" + +import pytest + +from coffee_machine.money_machine import MoneyMachine +from tests.conftest import InputFeeder + + +def test_report_prints_profit(capsys: pytest.CaptureFixture[str]) -> None: + MoneyMachine().report() + + assert capsys.readouterr().out == "Money: $0.00\n" + + +def test_process_coins_adds_up_coin_values(feed_input: InputFeeder) -> None: + feed_input(["1", "2", "3", "4"]) # quarters, dimes, nickels, pennies + + assert MoneyMachine().process_coins() == pytest.approx(0.64) + + +def test_process_coins_asks_again_on_invalid_count( + feed_input: InputFeeder, capsys: pytest.CaptureFixture[str] +) -> None: + feed_input(["abc", "-1", "4", "0", "0", "0"]) + + assert MoneyMachine().process_coins() == pytest.approx(1.0) + assert capsys.readouterr().out.count("whole number") == 2 + + +def test_make_payment_with_exact_amount_keeps_profit( + feed_input: InputFeeder, capsys: pytest.CaptureFixture[str] +) -> None: + feed_input(["6", "0", "0", "0"]) # $1.50 + machine = MoneyMachine() + + assert machine.make_payment(1.5) + assert machine.profit == pytest.approx(1.5) + assert "change" not in capsys.readouterr().out + + +def test_make_payment_with_excess_gives_change( + feed_input: InputFeeder, capsys: pytest.CaptureFixture[str] +) -> None: + feed_input(["12", "0", "0", "0"]) # $3.00 for a $2.50 drink + machine = MoneyMachine() + + assert machine.make_payment(2.5) + assert machine.profit == pytest.approx(2.5) + assert "$0.50 in change" in capsys.readouterr().out + + +def test_make_payment_when_short_refunds_and_keeps_no_profit( + feed_input: InputFeeder, capsys: pytest.CaptureFixture[str] +) -> None: + feed_input(["1", "0", "0", "0"]) + machine = MoneyMachine() + + assert not machine.make_payment(1.5) + assert machine.profit == 0.0 + assert "Money refunded" in capsys.readouterr().out From 0cdc814198df25ae9dd8c7806b25110f53912b2a Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 7 Oct 2026 10:51:55 +0800 Subject: [PATCH 2/2] Resolve pending commit link for RC-007 Link the Version History row of RC-007 to commit 2b6b3be and add the link definition. Refs #6 Refs #7 Refs #8 Refs #9 Refs #10 Refs #11 --- docs/sqa/reviews/rc-007-source-code-mil-002.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sqa/reviews/rc-007-source-code-mil-002.md b/docs/sqa/reviews/rc-007-source-code-mil-002.md index 5c8d0ea..921de58 100644 --- a/docs/sqa/reviews/rc-007-source-code-mil-002.md +++ b/docs/sqa/reviews/rc-007-source-code-mil-002.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-07 | Proposed | Jens Tirsvad Nielsen | S01 | Review record | pending | +| 2026-10-07 | Proposed | Jens Tirsvad Nielsen | S01 | Review record | [2b6b3be] | --- @@ -53,3 +53,4 @@ Go — all mandatory criteria pass or are not applicable with a stated reason. V [MIL-002]: ../../milestones/mil-002-core.md [QC-PY-001]: ../../../framework/qc/qc-programming-python.md +[2b6b3be]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/016-oop_coffee_machine/commit/2b6b3bee46e99d487792c79f722a79f9c921304d