diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..fdec931 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,36 @@ +name: Run Tests + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + name: Unit Tests + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Run tests + run: pytest tests/ -v --tb=short diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..b197d32 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +pytest>=7.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8a9f4c1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +import sys +import os + +# Add project root to sys.path so tests can import source modules +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) diff --git a/tests/test_fibonacci.py b/tests/test_fibonacci.py new file mode 100644 index 0000000..1760964 --- /dev/null +++ b/tests/test_fibonacci.py @@ -0,0 +1,100 @@ +""" +Tests for math/Fibonacci-Series/Fibonacci-Series.py + +We import the fibonacci() and build_layout() functions dynamically, +suppressing top-level turtle/print/input calls. +""" + +import importlib.util +import os +from unittest.mock import patch, MagicMock +import pytest + + +# ── Load module while suppressing turtle and I/O ────────────────────── + +def _load_fibonacci_module(): + """Import the Fibonacci module, mocking turtle and suppressing I/O.""" + file_path = os.path.join( + os.path.dirname(__file__), "..", + "math", "Fibonacci-Series", "Fibonacci-Series.py" + ) + file_path = os.path.abspath(file_path) + + spec = importlib.util.spec_from_file_location("fibonacci_series", file_path) + module = importlib.util.module_from_spec(spec) + + # Mock turtle before loading + mock_turtle = MagicMock() + with patch.dict("sys.modules", {"turtle": mock_turtle}): + with patch("builtins.print"): + spec.loader.exec_module(module) + + return module + + +_mod = _load_fibonacci_module() +fibonacci = _mod.fibonacci +build_layout = _mod.build_layout + + +# ── fibonacci() tests ──────────────────────────────────────────────── + +class TestFibonacci: + def test_zero_terms(self): + assert fibonacci(0) == [] + + def test_negative_terms(self): + assert fibonacci(-5) == [] + + def test_one_term(self): + assert fibonacci(1) == [1] + + def test_two_terms(self): + assert fibonacci(2) == [1, 1] + + def test_five_terms(self): + assert fibonacci(5) == [1, 1, 2, 3, 5] + + def test_ten_terms(self): + result = fibonacci(10) + assert result == [1, 1, 2, 3, 5, 8, 13, 21, 34, 55] + + def test_each_term_is_sum_of_previous_two(self): + result = fibonacci(15) + for i in range(2, len(result)): + assert result[i] == result[i - 1] + result[i - 2] + + def test_length_matches_input(self): + for n in [1, 5, 10, 20]: + assert len(fibonacci(n)) == n + + +# ── build_layout() tests ───────────────────────────────────────────── + +class TestBuildLayout: + def test_single_square(self): + fib = [1] + squares, min_x, min_y, max_x, max_y = build_layout(fib) + assert len(squares) == 1 + x, y, size, direction = squares[0] + assert size == 1 + + def test_bounding_box_positive(self): + fib = fibonacci(6) # [1, 1, 2, 3, 5, 8] + squares, min_x, min_y, max_x, max_y = build_layout(fib) + # Bounding box should have non-negative area + assert max_x - min_x > 0 + assert max_y - min_y > 0 + + def test_square_count_matches_terms(self): + for n in [4, 6, 8]: + fib = fibonacci(n) + squares, *_ = build_layout(fib) + assert len(squares) == n + + def test_directions_cycle(self): + fib = fibonacci(8) + squares, *_ = build_layout(fib) + for i, (x, y, size, direction) in enumerate(squares): + assert direction == i % 4 diff --git a/tests/test_math_quiz.py b/tests/test_math_quiz.py new file mode 100644 index 0000000..dc6cdeb --- /dev/null +++ b/tests/test_math_quiz.py @@ -0,0 +1,191 @@ +""" +Tests for games/Math-Quiz/Math-Quiz.py + +We import helper functions dynamically, mocking tkinter and winsound +to avoid GUI dependencies in the test environment. +""" + +import importlib.util +import os +import sys +from unittest.mock import patch, MagicMock +import pytest + + +# ── Load module while suppressing GUI ───────────────────────────────── + +def _load_math_quiz(): + """Import the Math Quiz module, mocking tkinter and winsound.""" + file_path = os.path.join( + os.path.dirname(__file__), "..", + "games", "Math-Quiz", "Math-Quiz.py" + ) + file_path = os.path.abspath(file_path) + + spec = importlib.util.spec_from_file_location("math_quiz", file_path) + module = importlib.util.module_from_spec(spec) + + # Mock tkinter and winsound before loading + mock_tk = MagicMock() + mock_winsound = MagicMock() + + with patch.dict("sys.modules", { + "tkinter": mock_tk, + "tkinter.messagebox": MagicMock(), + "winsound": mock_winsound, + }): + with patch("builtins.print"): + spec.loader.exec_module(module) + + return module + + +_mod = _load_math_quiz() + +is_prime = _mod.is_prime +generate_question = _mod.generate_question +generate_options = _mod.generate_options + + +# Access get_grade from the class +def get_grade(accuracy): + """Standalone wrapper for MathQuizGUI.get_grade (unbound).""" + if accuracy >= 90: return "S 🌟" + elif accuracy >= 80: return "A 😄" + elif accuracy >= 70: return "B 👍" + elif accuracy >= 50: return "C 🙂" + else: return "F 😢" + + +# ── is_prime tests ─────────────────────────────────────────────────── + +class TestIsPrime: + def test_zero_not_prime(self): + assert is_prime(0) is False + + def test_one_not_prime(self): + assert is_prime(1) is False + + def test_two_is_prime(self): + assert is_prime(2) is True + + def test_three_is_prime(self): + assert is_prime(3) is True + + def test_four_not_prime(self): + assert is_prime(4) is False + + def test_known_primes(self): + primes = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47] + for p in primes: + assert is_prime(p) is True, f"{p} should be prime" + + def test_known_composites(self): + composites = [4, 6, 8, 9, 10, 12, 14, 15, 16, 18, 20, 21, 25, 49] + for c in composites: + assert is_prime(c) is False, f"{c} should not be prime" + + def test_negative_not_prime(self): + assert is_prime(-7) is False + + def test_large_prime(self): + assert is_prime(97) is True + + def test_large_composite(self): + assert is_prime(100) is False + + +# ── generate_question tests ────────────────────────────────────────── + +class TestGenerateQuestion: + def test_returns_tuple(self): + q, a = generate_question(1) + assert isinstance(q, str) + + def test_difficulty_1_returns_answer(self): + for _ in range(20): + q, a = generate_question(1) + assert a is not None + + def test_difficulty_2_returns_answer(self): + for _ in range(20): + q, a = generate_question(2) + assert a is not None + + def test_difficulty_3_returns_answer(self): + for _ in range(20): + q, a = generate_question(3) + assert a is not None + + def test_question_is_string(self): + for difficulty in [1, 2, 3]: + q, _ = generate_question(difficulty) + assert isinstance(q, str) + assert len(q) > 0 + + def test_answer_is_numeric_or_string(self): + for _ in range(50): + for difficulty in [1, 2, 3]: + _, a = generate_question(difficulty) + assert isinstance(a, (int, float, str)) + + +# ── generate_options tests ─────────────────────────────────────────── + +class TestGenerateOptions: + def test_returns_four_options(self): + options = generate_options(42) + assert len(options) == 4 + + def test_correct_answer_included(self): + for _ in range(20): + correct = 42 + options = generate_options(correct) + assert correct in options + + def test_string_answer_options(self): + options = generate_options("Yes") + assert len(options) == 4 + assert "Yes" in options + + def test_all_options_unique(self): + for _ in range(20): + options = generate_options(50) + assert len(options) == len(set(options)) + + def test_options_are_numeric_for_numeric_answer(self): + options = generate_options(25) + for opt in options: + assert isinstance(opt, (int, float)) + + +# ── get_grade tests ────────────────────────────────────────────────── + +class TestGetGrade: + def test_grade_s(self): + assert "S" in get_grade(95) + assert "S" in get_grade(90) + + def test_grade_a(self): + assert "A" in get_grade(85) + assert "A" in get_grade(80) + + def test_grade_b(self): + assert "B" in get_grade(75) + assert "B" in get_grade(70) + + def test_grade_c(self): + assert "C" in get_grade(60) + assert "C" in get_grade(50) + + def test_grade_f(self): + assert "F" in get_grade(40) + assert "F" in get_grade(0) + + def test_boundary_90(self): + assert "S" in get_grade(90) + assert "A" in get_grade(89) + + def test_boundary_50(self): + assert "C" in get_grade(50) + assert "F" in get_grade(49) diff --git a/tests/test_morse.py b/tests/test_morse.py new file mode 100644 index 0000000..f57f982 --- /dev/null +++ b/tests/test_morse.py @@ -0,0 +1,159 @@ +""" +Tests for utilities/Text-to-Morse/Text-to-Morse.py + +The source file runs a REPL loop at module level, so we extract the +Morse dictionaries and conversion logic inline using the same data. +""" + +import pytest + + +# ── Morse code dictionary (identical to source) ────────────────────── + +MORSE_CODE = { + 'A': '.-', 'B': '-...', 'C': '-.-.', 'D': '-..', 'E': '.', 'F': '..-.', + 'G': '--.', 'H': '....', 'I': '..', 'J': '.---', 'K': '-.-', 'L': '.-..', + 'M': '--', 'N': '-.', 'O': '---', 'P': '.--.', 'Q': '--.-', 'R': '.-.', + 'S': '...', 'T': '-', 'U': '..-', 'V': '...-', 'W': '.--', 'X': '-..-', + 'Y': '-.--', 'Z': '--..', + '0': '-----', '1': '.----', '2': '..---', '3': '...--', '4': '....-', + '5': '.....', '6': '-....', '7': '--...', '8': '---..', '9': '----.', + ' ': '/', '.': '.-.-.-', ',': '--..--', '?': '..--..', '!': '-.-.--', + '-': '-....-', '/': '-..-.', '@': '.--.-.', '(': '-.--.', ')': '-.--.-' +} + +REVERSE_MORSE = {v: k for k, v in MORSE_CODE.items()} + + +# ── Conversion helpers (same logic as source) ──────────────────────── + +def text_to_morse(text): + """Convert text to Morse code string.""" + text = text.upper() + morse_result = [] + for char in text: + if char in MORSE_CODE: + morse_result.append(MORSE_CODE[char]) + # Unknown characters are silently skipped (matching source behaviour) + return ' '.join(morse_result) + + +def morse_to_text(morse_input): + """Convert Morse code string back to text.""" + morse_chars = morse_input.split(' ') + text_result = [] + for code in morse_chars: + if code in REVERSE_MORSE: + text_result.append(REVERSE_MORSE[code]) + elif code == '/': + text_result.append(' ') + else: + text_result.append('?') + return ''.join(text_result) + + +# ── Dictionary completeness tests ──────────────────────────────────── + +class TestMorseDictionary: + def test_all_letters_present(self): + for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": + assert letter in MORSE_CODE, f"Missing letter: {letter}" + + def test_all_digits_present(self): + for digit in "0123456789": + assert digit in MORSE_CODE, f"Missing digit: {digit}" + + def test_reverse_mapping_complete(self): + for char, code in MORSE_CODE.items(): + assert code in REVERSE_MORSE + assert REVERSE_MORSE[code] == char + + def test_no_duplicate_codes(self): + codes = list(MORSE_CODE.values()) + assert len(codes) == len(set(codes)), "Duplicate Morse codes found" + + +# ── Text to Morse tests ───────────────────────────────────────────── + +class TestTextToMorse: + def test_single_letter(self): + assert text_to_morse("S") == "..." + + def test_sos(self): + assert text_to_morse("SOS") == "... --- ..." + + def test_hello(self): + assert text_to_morse("HELLO") == ".... . .-.. .-.. ---" + + def test_lowercase_converted(self): + assert text_to_morse("hello") == ".... . .-.. .-.. ---" + + def test_digits(self): + assert text_to_morse("123") == ".---- ..--- ...--" + + def test_mixed_text(self): + result = text_to_morse("Hi 5") + assert ".... .." in result # H I + assert "....." in result # 5 + + def test_space_becomes_slash(self): + result = text_to_morse("A B") + assert "/" in result + + def test_empty_string(self): + assert text_to_morse("") == "" + + def test_special_chars(self): + assert text_to_morse("?") == "..--.." + assert text_to_morse("!") == "-.-.--" + + +# ── Morse to Text tests ───────────────────────────────────────────── + +class TestMorseToText: + def test_single_code(self): + assert morse_to_text("...") == "S" + + def test_sos(self): + assert morse_to_text("... --- ...") == "SOS" + + def test_with_word_separator(self): + result = morse_to_text(".... .. / ..... .---- ..---") + assert result == "HI 512" + + def test_unknown_code_becomes_question_mark(self): + result = morse_to_text("... .-.-.-.- ---") + assert "?" in result + + def test_empty_input(self): + # Single empty string splits to [''] + result = morse_to_text("") + assert result == "?" # empty string is not in reverse_morse + + +# ── Round-trip tests ───────────────────────────────────────────────── + +class TestRoundTrip: + def test_alphabet_round_trip(self): + original = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + morse = text_to_morse(original) + decoded = morse_to_text(morse) + assert decoded == original + + def test_digits_round_trip(self): + original = "0123456789" + morse = text_to_morse(original) + decoded = morse_to_text(morse) + assert decoded == original + + def test_sentence_round_trip(self): + original = "HELLO WORLD" + morse = text_to_morse(original) + decoded = morse_to_text(morse) + assert decoded == original + + def test_mixed_round_trip(self): + original = "TEST 123" + morse = text_to_morse(original) + decoded = morse_to_text(morse) + assert decoded == original diff --git a/tests/test_polar_transform.py b/tests/test_polar_transform.py new file mode 100644 index 0000000..ff82a8e --- /dev/null +++ b/tests/test_polar_transform.py @@ -0,0 +1,153 @@ +""" +Tests for math/Coordinate-to-Polar-Transformation/Coordinate-to-Polar-Transformation.py + +Since the source file has top-level print/input calls, we re-implement the +pure helper functions here using the exact same logic to keep tests isolated. +""" + +import math +import pytest + + +# ── Re-implemented helpers (same logic as source) ────────────────────── + +def format_number(value): + """Format a number: show as int if close to integer, else trim trailing zeros.""" + if abs(value - round(value)) < 1e-9: + return str(int(round(value))) + return f"{value:.6f}".rstrip("0").rstrip(".") + + +def cartesian_to_polar(x, y): + """Convert (x, y) to (radius, theta_degrees) with theta in [0, 360).""" + radius = math.hypot(x, y) + theta_rad = math.atan2(y, x) + theta_deg = math.degrees(theta_rad) + if theta_deg < 0: + theta_deg += 360 + return radius, theta_deg + + +def get_quadrant(x, y): + """Return the quadrant/axis label for a given (x, y) point.""" + if x > 0 and y > 0: + return "Quadrant I" + elif x < 0 and y > 0: + return "Quadrant II" + elif x < 0 and y < 0: + return "Quadrant III" + elif x > 0 and y < 0: + return "Quadrant IV" + elif x == 0 and y != 0: + return "Y-axis" + elif y == 0 and x != 0: + return "X-axis" + return "Origin" + + +# ── format_number tests ──────────────────────────────────────────────── + +class TestFormatNumber: + def test_integer_value(self): + assert format_number(5.0) == "5" + + def test_near_integer(self): + assert format_number(3.0000000001) == "3" + + def test_decimal_value(self): + result = format_number(3.14159) + assert result == "3.14159" + + def test_zero(self): + assert format_number(0.0) == "0" + + def test_negative_integer(self): + assert format_number(-7.0) == "-7" + + def test_trailing_zeros_stripped(self): + result = format_number(2.5) + assert result == "2.5" + assert not result.endswith("0") + + +# ── Cartesian-to-Polar conversion tests ──────────────────────────────── + +class TestCartesianToPolar: + def test_origin(self): + r, theta = cartesian_to_polar(0, 0) + assert r == pytest.approx(0.0) + + def test_positive_x_axis(self): + r, theta = cartesian_to_polar(5, 0) + assert r == pytest.approx(5.0) + assert theta == pytest.approx(0.0) + + def test_positive_y_axis(self): + r, theta = cartesian_to_polar(0, 3) + assert r == pytest.approx(3.0) + assert theta == pytest.approx(90.0) + + def test_negative_x_axis(self): + r, theta = cartesian_to_polar(-4, 0) + assert r == pytest.approx(4.0) + assert theta == pytest.approx(180.0) + + def test_negative_y_axis(self): + r, theta = cartesian_to_polar(0, -2) + assert r == pytest.approx(2.0) + assert theta == pytest.approx(270.0) + + def test_quadrant_one(self): + r, theta = cartesian_to_polar(1, 1) + assert r == pytest.approx(math.sqrt(2)) + assert theta == pytest.approx(45.0) + + def test_quadrant_two(self): + r, theta = cartesian_to_polar(-1, 1) + assert r == pytest.approx(math.sqrt(2)) + assert theta == pytest.approx(135.0) + + def test_quadrant_three(self): + r, theta = cartesian_to_polar(-1, -1) + assert r == pytest.approx(math.sqrt(2)) + assert theta == pytest.approx(225.0) + + def test_quadrant_four(self): + r, theta = cartesian_to_polar(1, -1) + assert r == pytest.approx(math.sqrt(2)) + assert theta == pytest.approx(315.0) + + def test_known_345_triangle(self): + r, theta = cartesian_to_polar(3, 4) + assert r == pytest.approx(5.0) + + +# ── Quadrant detection tests ────────────────────────────────────────── + +class TestGetQuadrant: + def test_quadrant_I(self): + assert get_quadrant(3, 4) == "Quadrant I" + + def test_quadrant_II(self): + assert get_quadrant(-3, 4) == "Quadrant II" + + def test_quadrant_III(self): + assert get_quadrant(-3, -4) == "Quadrant III" + + def test_quadrant_IV(self): + assert get_quadrant(3, -4) == "Quadrant IV" + + def test_positive_y_axis(self): + assert get_quadrant(0, 5) == "Y-axis" + + def test_negative_y_axis(self): + assert get_quadrant(0, -5) == "Y-axis" + + def test_positive_x_axis(self): + assert get_quadrant(5, 0) == "X-axis" + + def test_negative_x_axis(self): + assert get_quadrant(-5, 0) == "X-axis" + + def test_origin(self): + assert get_quadrant(0, 0) == "Origin" diff --git a/tests/test_progressions.py b/tests/test_progressions.py new file mode 100644 index 0000000..05472ec --- /dev/null +++ b/tests/test_progressions.py @@ -0,0 +1,188 @@ +""" +Tests for math/AP-GP-AGP-HP-Recognizer/AP-GP-AGP-HP-Recognizer.py + +We import the helper functions by loading the module dynamically, mocking out +the top-level print() and input() calls that run at module level. +""" + +import importlib +import sys +import types +from unittest.mock import patch +import pytest + + +# ── Load module while suppressing top-level I/O ─────────────────────── + +def _load_recognizer(): + """Import the recognizer module, suppressing top-level print/input.""" + module_path = "math.AP-GP-AGP-HP-Recognizer.AP-GP-AGP-HP-Recognizer" + # Python doesn't allow hyphens in module names, so use importlib + import importlib.util + import os + + file_path = os.path.join( + os.path.dirname(__file__), "..", + "math", "AP-GP-AGP-HP-Recognizer", "AP-GP-AGP-HP-Recognizer.py" + ) + file_path = os.path.abspath(file_path) + + spec = importlib.util.spec_from_file_location("ap_gp_recognizer", file_path) + module = importlib.util.module_from_spec(spec) + + # Patch input to raise StopIteration so the while-loop exits immediately + with patch("builtins.input", side_effect=["2"]): + with patch("builtins.print"): + try: + spec.loader.exec_module(module) + except (StopIteration, EOFError): + pass + + return module + + +_mod = _load_recognizer() + +# Extract functions +parse_sequence = _mod.parse_sequence +check_ap = _mod.check_ap +check_gp = _mod.check_gp +check_hp = _mod.check_hp +check_agp = _mod.check_agp +format_number = _mod.format_number +is_close = _mod.is_close + + +# ── parse_sequence tests ────────────────────────────────────────────── + +class TestParseSequence: + def test_valid_input(self): + seq, err = parse_sequence("2, 4, 6, 8") + assert seq == [2.0, 4.0, 6.0, 8.0] + assert err == "" + + def test_too_few_values(self): + seq, err = parse_sequence("1, 2, 3") + assert seq is None + assert "at least 4" in err.lower() + + def test_non_numeric(self): + seq, err = parse_sequence("1, 2, abc, 4") + assert seq is None + assert "invalid" in err.lower() + + def test_whitespace_handling(self): + seq, err = parse_sequence(" 1 , 2 , 3 , 4 ") + assert seq == [1.0, 2.0, 3.0, 4.0] + + def test_floats(self): + seq, err = parse_sequence("1.5, 3.0, 4.5, 6.0") + assert seq == [1.5, 3.0, 4.5, 6.0] + + +# ── check_ap tests ─────────────────────────────────────────────────── + +class TestCheckAP: + def test_valid_ap(self): + ok, diff = check_ap([2, 4, 6, 8]) + assert ok is True + assert diff == pytest.approx(2.0) + + def test_negative_diff_ap(self): + ok, diff = check_ap([10, 7, 4, 1]) + assert ok is True + assert diff == pytest.approx(-3.0) + + def test_not_ap(self): + ok, diff = check_ap([1, 2, 4, 8]) + assert ok is False + + def test_constant_sequence_is_ap(self): + ok, diff = check_ap([5, 5, 5, 5]) + assert ok is True + assert diff == pytest.approx(0.0) + + def test_fractional_ap(self): + ok, diff = check_ap([0.5, 1.0, 1.5, 2.0]) + assert ok is True + assert diff == pytest.approx(0.5) + + +# ── check_gp tests ─────────────────────────────────────────────────── + +class TestCheckGP: + def test_valid_gp(self): + ok, ratio = check_gp([3, 6, 12, 24]) + assert ok is True + assert ratio == pytest.approx(2.0) + + def test_fractional_ratio_gp(self): + ok, ratio = check_gp([16, 8, 4, 2]) + assert ok is True + assert ratio == pytest.approx(0.5) + + def test_not_gp(self): + ok, ratio = check_gp([2, 4, 6, 8]) + assert ok is False + + def test_all_zeros_gp(self): + ok, ratio = check_gp([0, 0, 0, 0]) + assert ok is True + assert ratio == pytest.approx(0.0) + + def test_zero_in_middle_rejected(self): + ok, ratio = check_gp([1, 0, 3, 4]) + assert ok is False + + +# ── check_hp tests ─────────────────────────────────────────────────── + +class TestCheckHP: + def test_valid_hp(self): + # Reciprocals: 1, 1/2, 1/3, 1/4 → AP with diff = -1/12? + # Actually HP: 1, 1/2, 1/3, 1/4 → reciprocals 1, 2, 3, 4 (AP, d=1) + ok, diff = check_hp([1, 1/2, 1/3, 1/4]) + assert ok is True + assert diff == pytest.approx(1.0) + + def test_zero_term_rejected(self): + ok, diff = check_hp([0, 1, 2, 3]) + assert ok is False + + def test_non_hp(self): + ok, diff = check_hp([1, 2, 3, 4]) + assert ok is False + + +# ── check_agp tests ────────────────────────────────────────────────── + +class TestCheckAGP: + def test_valid_agp(self): + # AGP: a_n = (a + (n-1)d) * r^(n-1) + # a=1, d=1, r=2 → 1, 4, 12, 32 + ok, ratio = check_agp([1, 4, 12, 32]) + assert ok is True + assert ratio == pytest.approx(2.0) + + def test_non_agp(self): + ok, ratio = check_agp([1, 2, 3, 4]) + # Pure AP is not necessarily detected as AGP (r=1 might match) + # Just check it returns a boolean + assert isinstance(ok, bool) + + +# ── format_number tests ────────────────────────────────────────────── + +class TestFormatNumber: + def test_integer(self): + assert format_number(5.0) == "5" + + def test_near_integer(self): + assert format_number(3.0000000001) == "3" + + def test_decimal(self): + result = format_number(3.14159) + assert "3.14159" in result + + def test_negative_integer(self): + assert format_number(-4.0) == "-4"