diff --git a/pyproject.toml b/pyproject.toml index 52745c6..7dbf30f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,9 @@ dependencies = [] [project.optional-dependencies] dev = ["pytest>=8"] +[project.scripts] +calc = "calc.calculator:main" + [tool.setuptools.packages.find] where = ["src"] diff --git a/src/calc/__main__.py b/src/calc/__main__.py new file mode 100644 index 0000000..801b7c3 --- /dev/null +++ b/src/calc/__main__.py @@ -0,0 +1,9 @@ +"""! +@file __main__.py +@brief Allows running the calculator with `python -m calc`. +""" + +from calc.calculator import main + +if __name__ == "__main__": + main() diff --git a/src/calc/calculator.py b/src/calc/calculator.py new file mode 100644 index 0000000..9162c96 --- /dev/null +++ b/src/calc/calculator.py @@ -0,0 +1,112 @@ +"""! +@file calculator.py +@brief The interactive console calculator. +""" + +from calc.constants import ( + ANSWER_CONTINUE, + ANSWER_NEW, + ANSWER_QUIT, + ASCII_TITLE, + MESSAGE_DIVIDE_BY_ZERO, + MESSAGE_GOODBYE, + MESSAGE_INVALID_ANSWER, + MESSAGE_INVALID_NUMBER, + MESSAGE_INVALID_OPERATION, + PROMPT_AGAIN, + PROMPT_FIRST_NUMBER, + PROMPT_NEXT_NUMBER, + PROMPT_OPERATION, +) +from calc.operations import OPERATIONS + + +def format_number(value: float) -> str: + """! + @brief Format a number without a trailing ".0" for whole values. + @param value The number to format. + @return The text to show the user. + """ + return str(int(value)) if value.is_integer() else str(value) + + +def read_number(prompt: str) -> float: + """! + @brief Ask for a number until the user types a valid one. + @param prompt The text shown to the user. + @return The number typed by the user. + """ + while True: + try: + return float(input(prompt)) + except ValueError: + print(MESSAGE_INVALID_NUMBER) + + +def read_operation() -> str: + """! + @brief Show the available operations and ask for one until it is valid. + @return The symbol of the chosen operation, a key of OPERATIONS. + """ + for symbol in OPERATIONS: + print(symbol) + while True: + symbol = input(PROMPT_OPERATION).strip() + if symbol in OPERATIONS: + return symbol + print(MESSAGE_INVALID_OPERATION) + + +def read_answer(result: float) -> str: + """! + @brief Ask whether to continue with the result, start anew or quit. + @param result The result of the last calculation. + @return One of ANSWER_CONTINUE, ANSWER_NEW or ANSWER_QUIT. + """ + while True: + answer = input(PROMPT_AGAIN.format(result=format_number(result))) + answer = answer.strip().lower() + if answer in (ANSWER_CONTINUE, ANSWER_NEW, ANSWER_QUIT): + return answer + print(MESSAGE_INVALID_ANSWER) + + +def calculator() -> None: + """! + @brief Run one calculation session. + + The user may continue with the previous result. Starting a new + calculation calls this function again (recursion), which gives a clean + restart. A division by zero also restarts the calculator. + """ + num1 = read_number(PROMPT_FIRST_NUMBER) + while True: + symbol = read_operation() + num2 = read_number(PROMPT_NEXT_NUMBER) + try: + result = OPERATIONS[symbol](num1, num2) + except ZeroDivisionError: + print(MESSAGE_DIVIDE_BY_ZERO) + calculator() + return + print( + f"{format_number(num1)} {symbol} {format_number(num2)} " + f"= {format_number(result)}" + ) + answer = read_answer(result) + if answer == ANSWER_CONTINUE: + num1 = result + elif answer == ANSWER_NEW: + calculator() + return + else: + print(MESSAGE_GOODBYE) + return + + +def main() -> None: + """! + @brief Program entry point: show the ASCII title and start the calculator. + """ + print(ASCII_TITLE) + calculator() diff --git a/src/calc/constants.py b/src/calc/constants.py index 3e45c2f..39a224d 100644 --- a/src/calc/constants.py +++ b/src/calc/constants.py @@ -29,26 +29,17 @@ MESSAGE_INVALID_ANSWER: str = "Please answer with one of the listed letters." MESSAGE_DIVIDE_BY_ZERO: str = "You cannot divide by zero." MESSAGE_GOODBYE: str = "Goodbye!" -## ASCII title shown when the program starts. +## ASCII title shown at start: a calculator with the lettering to its right. ASCII_TITLE: str = r""" - ____ _ _ _ - / ___|__ _ | | ___ _ _| | __ _| |_ ___ _ __ -| | / _` | | |/ __| | | | |/ _` | __/ _ \| '__| -| |__| (_| | | | (__| |_| | | (_| | || (_) | | - \____\__,_| |_|\___|\__,_|_|\__,_|\__\___/|_| -""" - -## ASCII calculator shown under the title. -ASCII_CALCULATOR: str = r""" _____________________ | _________________ | | | 0 | | | |_________________| | -| ___ ___ ___ ___ | -| | 7 | 8 | 9 | | + | | -| |___|___|___| |___| | -| | 4 | 5 | 6 | | - | | -| |___|___|___| |___| | +| ___ ___ ___ ___ | ____ _ _ _ +| | 7 | 8 | 9 | | + | | / ___|__ _ | | ___ _ _| | __ _| |_ ___ _ __ +| |___|___|___| |___| | | | / _` | | |/ __| | | | |/ _` | __/ _ \| '__| +| | 4 | 5 | 6 | | - | | | |__| (_| | | | (__| |_| | | (_| | || (_) | | +| |___|___|___| |___| | \____\__,_| |_|\___|\__,_|_|\__,_|\__\___/|_| | | 1 | 2 | 3 | | x | | | |___|___|___| |___| | | | . | 0 | = | | / | | diff --git a/tests/test_calculator.py b/tests/test_calculator.py new file mode 100644 index 0000000..767bb62 --- /dev/null +++ b/tests/test_calculator.py @@ -0,0 +1,79 @@ +"""! +@file test_calculator.py +@brief Tests for the interactive calculator loop. +""" + +import pytest + +from calc.calculator import calculator, format_number, main + + +def feed(monkeypatch: pytest.MonkeyPatch, answers: list[str]) -> None: + """! + @brief Make input() return the given answers in order. + @param monkeypatch The pytest monkeypatch fixture. + @param answers The lines the "user" types. + """ + lines = iter(answers) + monkeypatch.setattr("builtins.input", lambda _prompt="": next(lines)) + + +def test_format_number_drops_trailing_zero() -> None: + assert format_number(5.0) == "5" + assert format_number(2.5) == "2.5" + + +def test_calculator_adds_then_quits( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + feed(monkeypatch, ["2", "+", "3", "q"]) + calculator() + assert "2 + 3 = 5" in capsys.readouterr().out + + +def test_calculator_continues_with_previous_result( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + feed(monkeypatch, ["2", "+", "3", "y", "*", "4", "q"]) + calculator() + assert "5 * 4 = 20" in capsys.readouterr().out + + +def test_calculator_starts_new_calculation( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + feed(monkeypatch, ["2", "+", "3", "n", "10", "/", "4", "q"]) + calculator() + assert "10 / 4 = 2.5" in capsys.readouterr().out + + +def test_calculator_reprompts_on_invalid_input( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + feed(monkeypatch, ["abc", "1", "^", "+", "x", "1", "?", "q"]) + calculator() + out = capsys.readouterr().out + assert "not a number" in out + assert "not a valid operation" in out + assert "Please answer" in out + assert "1 + 1 = 2" in out + + +def test_calculator_restarts_after_division_by_zero( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + feed(monkeypatch, ["1", "/", "0", "6", "-", "1", "q"]) + calculator() + out = capsys.readouterr().out + assert "cannot divide by zero" in out + assert "6 - 1 = 5" in out + + +def test_main_shows_ascii_art( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + feed(monkeypatch, ["1", "+", "1", "q"]) + main() + out = capsys.readouterr().out + assert "|_____________________|" in out + assert "Goodbye!" in out