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..921de58 --- /dev/null +++ b/docs/sqa/reviews/rc-007-source-code-mil-002.md @@ -0,0 +1,56 @@ +# 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 | [2b6b3be] | + +--- + +## 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 +[2b6b3be]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/016-oop_coffee_machine/commit/2b6b3bee46e99d487792c79f722a79f9c921304d 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