testsAndMisc/python_pkg/code_tutor/tests/test_challenge.py
Krzysztof kuhy Rudnicki 2d0d69b461 Add code_tutor: interactive codebase-learning CLI
New python_pkg/code_tutor package that turns an unfamiliar codebase into
an interactive lesson: analyse code into items (_analyzer), order them
dependency-first (_plan_builder), pose reimplement challenges
(_challenge + _challenge_support), verify user implementations with tests
and coverage (_verifier), track progress (_progress/_session), and offer
local-LLM hints (_llm), driven by a Typer/Rich CLI (cli).

- meta/pyproject.toml + .pre-commit-config.yaml + meta/requirements.txt:
  add anthropic/rich/typer deps, a cli.py per-file-ignore (Typer B008 etc),
  and code_tutor-specific ruff/pylint/mypy tuning.
- _challenge.py and its tests are split across _challenge_support.py and
  test_challenge_part2/part3.py to stay under the 500-line file cap.

Verification: pytest 631 passed at 100% line+branch coverage; every file
<=500 lines; pre-commit (ruff ALL, mypy strict, pylint 10/10, bandit,
evidence + multi-file contract) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RRCdR4D8SaVrN2xraMQxM
2026-07-11 00:15:46 +02:00

347 lines
12 KiB
Python

"""Tests for python_pkg.code_tutor._challenge_support -- part 1.
Covers the test-discovery and pytest-execution helpers:
``_scan_test_file``, ``_find_tests``, ``_show_test_panels``,
``_collect_lines``, ``_project_root``, ``_pytest_clean`` and
``_patch_and_test``.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
from python_pkg.code_tutor._challenge_support import (
_collect_lines,
_find_tests,
_patch_and_test,
_project_root,
_pytest_clean,
_scan_test_file,
_show_test_panels,
)
from python_pkg.code_tutor.tests.conftest import _item
# ---------------------------------------------------------------------------
# _scan_test_file
# ---------------------------------------------------------------------------
def test_scan_test_file_module_function_match() -> None:
source = "def test_fn_does_thing():\n fn(1)\n"
result = _scan_test_file(source, "fn")
assert "test_fn_does_thing" in result
def test_scan_test_file_class_method_match() -> None:
source = "class TestFoo:\n def test_fn_works(self):\n fn()\n"
result = _scan_test_file(source, "fn")
assert any("test_fn_works" in r for r in result)
def test_scan_test_file_no_match() -> None:
source = "def test_other():\n other()\n"
result = _scan_test_file(source, "fn")
assert result == []
def test_scan_test_file_syntax_error() -> None:
result = _scan_test_file("def (broken):", "fn")
assert result == []
def test_scan_test_file_non_test_function_skipped() -> None:
source = "def helper_fn():\n fn()\n"
result = _scan_test_file(source, "fn")
assert result == []
# ---------------------------------------------------------------------------
# _find_tests
# ---------------------------------------------------------------------------
def test_find_tests_found(tmp_path: Path) -> None:
(tmp_path / "test_mod.py").write_text(
"def test_fn_works():\n fn()\n", encoding="utf-8"
)
result = _find_tests(_item(), tmp_path)
assert len(result) == 1
assert result[0][1] == ["test_fn_works"]
def test_find_tests_no_name_in_source(tmp_path: Path) -> None:
(tmp_path / "test_other.py").write_text(
"def test_other_works():\n other()\n", encoding="utf-8"
)
result = _find_tests(_item(), tmp_path)
assert result == []
def test_find_tests_oserror(tmp_path: Path) -> None:
test_file = tmp_path / "test_mod.py"
test_file.write_text("def test_fn(): fn()\n", encoding="utf-8")
with patch("pathlib.Path.read_text", side_effect=OSError("perm")):
result = _find_tests(_item(), tmp_path)
assert result == []
def test_find_tests_no_matching_node_ids(tmp_path: Path) -> None:
# File mentions 'fn' in source but not in test function bodies
(tmp_path / "test_mod.py").write_text(
"# fn is mentioned here\ndef test_other():\n pass\n", encoding="utf-8"
)
result = _find_tests(_item(), tmp_path)
assert result == []
# ---------------------------------------------------------------------------
# _show_test_panels
# ---------------------------------------------------------------------------
def test_show_test_panels_normal(tmp_path: Path) -> None:
source = "def test_fn():\n fn(1)\n assert True\n"
test_file = tmp_path / "test_mod.py"
test_file.write_text(source, encoding="utf-8")
mock_console = MagicMock()
with patch("python_pkg.code_tutor._challenge_support.Syntax"):
_show_test_panels([(test_file, ["test_fn"])], mock_console)
assert mock_console.print.called
def test_show_test_panels_class_method(tmp_path: Path) -> None:
source = "class TestFoo:\n def test_fn(self):\n fn()\n"
test_file = tmp_path / "test_mod.py"
test_file.write_text(source, encoding="utf-8")
mock_console = MagicMock()
with patch("python_pkg.code_tutor._challenge_support.Syntax"):
_show_test_panels([(test_file, ["TestFoo::test_fn"])], mock_console)
assert mock_console.print.called
def test_show_test_panels_oserror(tmp_path: Path) -> None:
test_file = tmp_path / "test_mod.py"
test_file.write_text("def test_fn(): pass\n", encoding="utf-8")
mock_console = MagicMock()
with patch("pathlib.Path.read_text", side_effect=OSError("perm")):
_show_test_panels([(test_file, ["test_fn"])], mock_console)
mock_console.print.assert_not_called()
def test_show_test_panels_node_not_in_func_names(tmp_path: Path) -> None:
source = "def test_fn():\n fn()\n"
test_file = tmp_path / "test_mod.py"
test_file.write_text(source, encoding="utf-8")
mock_console = MagicMock()
with patch("python_pkg.code_tutor._challenge_support.Syntax"):
# Pass a node_id that doesn't match any function name
_show_test_panels([(test_file, ["test_other"])], mock_console)
mock_console.print.assert_not_called()
# ---------------------------------------------------------------------------
# _collect_lines
# ---------------------------------------------------------------------------
def test_collect_lines_end() -> None:
responses = iter(["line one", "line two", "END"])
mock_console = MagicMock()
result = _collect_lines("prompt", mock_console, lambda _: next(responses))
assert result == "line one\nline two"
def test_collect_lines_skip() -> None:
mock_console = MagicMock()
result = _collect_lines("prompt", mock_console, lambda _: "skip")
assert result is None
# ---------------------------------------------------------------------------
# _project_root
# ---------------------------------------------------------------------------
def test_project_root_finds_pyproject(tmp_path: Path) -> None:
(tmp_path / "pyproject.toml").write_text("[project]", encoding="utf-8")
sub = tmp_path / "src" / "pkg"
sub.mkdir(parents=True)
result = _project_root(sub)
assert result == tmp_path
def test_project_root_finds_setup_py(tmp_path: Path) -> None:
(tmp_path / "setup.py").write_text("# setup", encoding="utf-8")
result = _project_root(tmp_path)
assert result == tmp_path
def test_project_root_not_found(tmp_path: Path) -> None:
# Create a dir deep inside tmp_path with no pyproject/setup
deep = tmp_path / "a" / "b" / "c"
deep.mkdir(parents=True)
# Walk up to root (tmp_path's parent and above) without finding the files
# We need a completely isolated path — create one without any pyproject.toml
import tempfile
with tempfile.TemporaryDirectory() as td:
isolated = Path(td) / "pkg"
isolated.mkdir()
result = _project_root(isolated)
# When not found, returns start.resolve()
assert result == isolated.resolve()
# ---------------------------------------------------------------------------
# _pytest_clean
# ---------------------------------------------------------------------------
def test_pytest_clean_pass() -> None:
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "1 passed"
mock_result.stderr = ""
mock_console = MagicMock()
with patch(
"python_pkg.code_tutor._challenge_support.subprocess.run",
return_value=mock_result,
):
assert (
_pytest_clean(["test_mod.py::test_fn"], Path("/proj"), mock_console) is True
)
def test_pytest_clean_fail() -> None:
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = ""
mock_result.stderr = "FAILED"
mock_console = MagicMock()
with patch(
"python_pkg.code_tutor._challenge_support.subprocess.run",
return_value=mock_result,
):
assert (
_pytest_clean(["test_mod.py::test_fn"], Path("/proj"), mock_console)
is False
)
def test_pytest_clean_no_output() -> None:
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = ""
mock_result.stderr = ""
mock_console = MagicMock()
with patch(
"python_pkg.code_tutor._challenge_support.subprocess.run",
return_value=mock_result,
):
_pytest_clean([], Path("/proj"), mock_console)
mock_console.print.assert_not_called()
# ---------------------------------------------------------------------------
# _patch_and_test
# ---------------------------------------------------------------------------
def test_patch_and_test_syntax_error(tmp_path: Path) -> None:
source = "def fn():\n return 1\n"
(tmp_path / "mod.py").write_text(source, encoding="utf-8")
mock_console = MagicMock()
result = _patch_and_test(
_item(file="mod.py", start=1, end=2),
str(tmp_path),
"def fn(\n broken syntax",
[],
mock_console,
)
assert result is False
# Original file should be restored
assert (tmp_path / "mod.py").read_text(encoding="utf-8") == source
def test_patch_and_test_passes(tmp_path: Path) -> None:
source = "def fn():\n return 1\n"
(tmp_path / "mod.py").write_text(source, encoding="utf-8")
test_file = tmp_path / "test_mod.py"
test_file.write_text("def test_fn(): pass\n", encoding="utf-8")
mock_console = MagicMock()
with patch(
"python_pkg.code_tutor._challenge_support._pytest_clean", return_value=True
):
result = _patch_and_test(
_item(file="mod.py", start=1, end=2),
str(tmp_path),
"def fn():\n return 99",
[(test_file, ["test_fn"])],
mock_console,
)
assert result is True
# File restored
assert (tmp_path / "mod.py").read_text(encoding="utf-8") == source
def test_patch_and_test_fails(tmp_path: Path) -> None:
source = "def fn():\n return 1\n"
(tmp_path / "mod.py").write_text(source, encoding="utf-8")
test_file = tmp_path / "test_mod.py"
test_file.write_text("def test_fn(): pass\n", encoding="utf-8")
mock_console = MagicMock()
with patch(
"python_pkg.code_tutor._challenge_support._pytest_clean", return_value=False
):
result = _patch_and_test(
_item(file="mod.py", start=1, end=2),
str(tmp_path),
"def fn():\n return 0",
[(test_file, ["test_fn"])],
mock_console,
)
assert result is False
assert (tmp_path / "mod.py").read_text(encoding="utf-8") == source
def test_scan_test_file_non_func_non_class_toplevel() -> None:
"""Skip a top-level node that is neither a function nor a class def.
A module-level ``import`` fails both ``isinstance`` checks, exercising
the branch that falls through the ``elif ClassDef`` back to the loop.
"""
source = "import os\ndef test_fn_uses_fn():\n fn()\n"
result = _scan_test_file(source, "fn")
assert result == ["test_fn_uses_fn"]
def test_show_test_panels_non_func_non_class_toplevel(tmp_path: Path) -> None:
"""Skip a top-level node that is neither a function nor a class def.
A module-level ``import`` yields an empty ``nodes`` list, so the
``elif ClassDef`` branch is not taken before the inner render loop.
"""
source = "import os\ndef test_fn():\n fn(1)\n"
test_file = tmp_path / "test_mod.py"
test_file.write_text(source, encoding="utf-8")
mock_console = MagicMock()
with patch("python_pkg.code_tutor._challenge_support.Syntax"):
_show_test_panels([(test_file, ["test_fn"])], mock_console)
assert mock_console.print.called
def test_show_test_panels_class_non_func_member(tmp_path: Path) -> None:
"""Skip a class-body member that is not a function definition.
The class-level ``attr = 1`` assignment fails the member ``isinstance``
check, exercising the branch that loops back to the next body member.
"""
source = "class TestFoo:\n attr = 1\n def test_fn(self):\n fn()\n"
test_file = tmp_path / "test_mod.py"
test_file.write_text(source, encoding="utf-8")
mock_console = MagicMock()
with patch("python_pkg.code_tutor._challenge_support.Syntax"):
_show_test_panels([(test_file, ["TestFoo::test_fn"])], mock_console)
assert mock_console.print.called