Bring Phase 2 (MIL-002) into main #19
@@ -15,7 +15,7 @@ document of a type. `Primary File` may contain a glob (e.g.
|
|||||||
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
|
||||||
| PP | Project Plan | docs/project-plan.md | 002 |
|
| PP | Project Plan | docs/project-plan.md | 002 |
|
||||||
| MIL | Milestone / Gateway | docs/milestones/mil-*.md | 004 |
|
| 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 |
|
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||||
|
|
||||||
## Languages
|
## Languages
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""! @file
|
||||||
|
@brief Allows `python -m coffee_machine`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from coffee_machine.main import main
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -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))
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""! @file
|
"""! @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
|
from typing import Final
|
||||||
@@ -11,11 +11,18 @@ MILK: Final = "milk"
|
|||||||
COFFEE: Final = "coffee"
|
COFFEE: Final = "coffee"
|
||||||
## @}
|
## @}
|
||||||
|
|
||||||
## @brief Cost in dollars and ingredient amounts per drink.
|
## @brief Price in dollars of each drink.
|
||||||
MENU: Final[dict[str, dict[str, object]]] = {
|
DRINK_COSTS: Final[dict[str, float]] = {
|
||||||
"espresso": {"cost": 1.5, "ingredients": {WATER: 50, MILK: 0, COFFEE: 18}},
|
"espresso": 1.5,
|
||||||
"latte": {"cost": 2.5, "ingredients": {WATER: 200, MILK: 150, COFFEE: 24}},
|
"latte": 2.5,
|
||||||
"cappuccino": {"cost": 3.0, "ingredients": {WATER: 250, MILK: 100, COFFEE: 24}},
|
"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.
|
## @brief Amount of each ingredient the machine starts with.
|
||||||
@@ -38,8 +45,25 @@ INITIAL_PROFIT: Final = 0.0
|
|||||||
## @brief Currency symbol shown in messages.
|
## @brief Currency symbol shown in messages.
|
||||||
CURRENCY: Final = "$"
|
CURRENCY: Final = "$"
|
||||||
|
|
||||||
|
## @brief Number of decimals of an amount of money.
|
||||||
|
MONEY_DECIMALS: Final = 2
|
||||||
|
|
||||||
## @name Commands at the prompt
|
## @name Commands at the prompt
|
||||||
## @{
|
## @{
|
||||||
COMMAND_OFF: Final = "off"
|
COMMAND_OFF: Final = "off"
|
||||||
COMMAND_REPORT: Final = "report"
|
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!"
|
||||||
|
## @}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -5,10 +5,14 @@
|
|||||||
from coffee_machine import constants
|
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:
|
def test_every_drink_uses_known_ingredients() -> None:
|
||||||
known = set(constants.INITIAL_RESOURCES)
|
known = set(constants.INITIAL_RESOURCES)
|
||||||
for drink in constants.MENU.values():
|
for ingredients in constants.DRINK_INGREDIENTS.values():
|
||||||
assert set(drink["ingredients"]) <= known # type: ignore[call-overload]
|
assert set(ingredients) <= known
|
||||||
|
|
||||||
|
|
||||||
def test_every_resource_has_a_unit() -> None:
|
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:
|
def test_commands_are_not_drink_names() -> None:
|
||||||
assert constants.COMMAND_OFF not in constants.MENU
|
assert constants.COMMAND_OFF not in constants.DRINK_COSTS
|
||||||
assert constants.COMMAND_REPORT not in constants.MENU
|
assert constants.COMMAND_REPORT not in constants.DRINK_COSTS
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user