diff --git a/fixit/cli.py b/fixit/cli.py index 52d4ffb..b2baf2f 100644 --- a/fixit/cli.py +++ b/fixit/cli.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Optional -from fixit import __version__, convert, jsonfmt, password, textutils, wordcount +from fixit import __version__, convert, jsonfmt, password, textutils, wordcount, roman CONVERSIONS = { "c2f": convert.celsius_to_fahrenheit, @@ -75,6 +75,11 @@ def run_top_words(args: argparse.Namespace) -> None: def run_slugify(args: argparse.Namespace) -> None: print(textutils.slugify(args.text)) +def run_roman(args: argparse.Namespace) -> None: + if args.value.isdigit(): + print(roman.to_roman(int(args.value))) + else: + print(roman.from_roman(args.value)) COMMAND_TABLE = { "wc": run_wc, @@ -86,6 +91,7 @@ def run_slugify(args: argparse.Namespace) -> None: "reverse": run_reverse, "slugify": run_slugify, "top-words": run_top_words, + "roman" : run_roman, } @@ -136,6 +142,9 @@ def build_parser() -> argparse.ArgumentParser: "--n", type=int, default=3, help="number of words to show (default: 3)" ) + rom = sub.add_parser("roman", help="check if value is a roman") + rom.add_argument("value") + return parser diff --git a/fixit/roman.py b/fixit/roman.py new file mode 100644 index 0000000..e774141 --- /dev/null +++ b/fixit/roman.py @@ -0,0 +1,33 @@ +ROMAN_VALUES = [ + (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), + (100, "C"), (90, "XC"), (50, "L"), (40, "XL"), + (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"), +] + +def to_roman(number: int) -> str: + + """Convert an integer from 1 to 3999 to a Roman numeral.""" + + if number < 1 or number > 3999: + raise ValueError("Error: number must be between 0 and 1000") + + result = "" + for value, symbol in ROMAN_VALUES: + while number >= value: + result += symbol + number -= value + return result + +def from_roman(numeral: str) -> int: + """Convert a Roman numeral to an integer.""" + + text = numeral.upper() + total = 0 + pos = 0 + for value, symbol in ROMAN_VALUES: + while text.startswith(symbol, pos): + total += value + pos += len(symbol) + if total == 0 or to_roman(total) != text: + raise ValueError(f"Error: '{numeral}' is not a valid Roman numeral") + return total \ No newline at end of file diff --git a/tests/test_roman.py b/tests/test_roman.py new file mode 100644 index 0000000..fda9e4b --- /dev/null +++ b/tests/test_roman.py @@ -0,0 +1,57 @@ +import pytest + +from fixit.roman import from_roman, to_roman + + +# --- to_roman: normal and edge cases --- + +def test_to_roman_1994(): + assert to_roman(1994) == "MCMXCIV" + + +def test_to_roman_smallest(): + assert to_roman(1) == "I" # what is 1 in Roman? + + +def test_to_roman_largest(): + assert to_roman(3999) == "MMMCMXCIX" # the biggest Roman numeral (you saw it earlier) + + +def test_to_roman_rejects_zero(): + with pytest.raises(ValueError): + to_roman(0) # just below the allowed range + + +def test_to_roman_rejects_4000(): + with pytest.raises(ValueError): + to_roman(4000) # just above the allowed range + + +# --- from_roman: normal and edge cases --- + +def test_from_roman_1994(): + assert from_roman("MCMXCIV") == 1994 # the number + + +def test_from_roman_lowercase(): + assert from_roman("mcmxciv") == 1994 # same numeral, in lowercase letters + + +def test_from_roman_rejects_four_in_a_row(): + with pytest.raises(ValueError): + from_roman("IIII") # 4 written the WRONG way (4 of the same letter) + + +def test_from_roman_rejects_bad_pair(): + with pytest.raises(ValueError): + from_roman("IC") + + +def test_from_roman_rejects_empty(): + with pytest.raises(ValueError): + from_roman("") # an empty string + + +def test_from_roman_rejects_letters(): + with pytest.raises(ValueError): + from_roman("ABC") \ No newline at end of file