testsAndMisc/python_pkg/code_tutor/tests/test_plan_builder.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

178 lines
5.5 KiB
Python

"""Tests for python_pkg.code_tutor._plan_builder."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from python_pkg.code_tutor._analyzer import CodeItem
from python_pkg.code_tutor._plan_builder import (
_order_items,
_session_title,
_toposort_files,
build_plan,
)
def _item(file: str, name: str = "fn", start: int = 1, end: int = 5) -> CodeItem:
return CodeItem(
id=f"{file}.{name}",
file=file,
type="function",
name=name,
start_line=start,
end_line=end,
)
# ---------------------------------------------------------------------------
# _toposort_files
# ---------------------------------------------------------------------------
def test_toposort_files_no_deps() -> None:
a = Path("a.py")
b = Path("b.py")
deps = {a: set(), b: set()}
result = _toposort_files(deps)
assert set(result) == {a, b}
def test_toposort_files_with_deps() -> None:
a = Path("a.py")
b = Path("b.py")
deps = {a: set(), b: {a}}
result = _toposort_files(deps)
assert result.index(a) < result.index(b)
def test_toposort_files_cycle() -> None:
a = Path("a.py")
b = Path("b.py")
deps = {a: {b}, b: {a}}
result = _toposort_files(deps)
assert set(result) == {a, b}
# ---------------------------------------------------------------------------
# _order_items
# ---------------------------------------------------------------------------
def test_order_items_empty(tmp_path: Path) -> None:
assert _order_items([], tmp_path) == []
def test_order_items_no_py_files(tmp_path: Path) -> None:
items = [_item("main.go"), _item("lib.go")]
result = _order_items(items, tmp_path)
assert len(result) == 2
def test_order_items_py_files(tmp_path: Path) -> None:
(tmp_path / "a.py").write_text("def fn(): pass\n", encoding="utf-8")
(tmp_path / "b.py").write_text("import a\ndef fn2(): pass\n", encoding="utf-8")
items = [_item("a.py"), _item("b.py", name="fn2")]
result = _order_items(items, tmp_path)
assert len(result) == 2
def test_order_items_mixed(tmp_path: Path) -> None:
(tmp_path / "mod.py").write_text("def fn(): pass\n", encoding="utf-8")
items = [_item("mod.py"), _item("script.go")]
result = _order_items(items, tmp_path)
assert len(result) == 2
def test_order_items_toposort_file_outside_codebase(tmp_path: Path) -> None:
"""A toposorted file outside the codebase root is skipped.
Covers the ``is_relative_to`` False branch: an import can resolve to a
path outside the codebase, which must not be added to the ordering map.
"""
module = "python_pkg.code_tutor._plan_builder"
outside = Path("/somewhere/else/x.py")
items = [_item("a.py")]
with (
patch(f"{module}.get_python_files", return_value=[tmp_path / "a.py"]),
patch(f"{module}.get_file_imports", return_value=set()),
patch(f"{module}._toposort_files", return_value=[outside]),
):
result = _order_items(items, tmp_path)
assert len(result) == 1
# ---------------------------------------------------------------------------
# _session_title
# ---------------------------------------------------------------------------
def test_session_title_empty() -> None:
assert _session_title([]) == "untitled"
def test_session_title_single() -> None:
items = [_item("src/main.py")]
assert _session_title(items) == "src"
def test_session_title_same_dir() -> None:
items = [_item("src/a.py"), _item("src/b.py")]
assert _session_title(items) == "src"
def test_session_title_common_parent_root(tmp_path: Path) -> None:
# parent of "src" is ".", so common becomes "."
items = [_item("src/a.py"), _item("src/b/c.py")]
result = _session_title(items)
# common becomes "." because parent of "src" is Path(".")
assert result == "a"
def test_session_title_common_parent_equals_common(tmp_path: Path) -> None:
# dirs differ: common goes up from "pkg/sub" to parent "pkg"
items = [_item("pkg/sub/a.py"), _item("pkg/sub/deeper/b.py")]
result = _session_title(items)
assert result == "pkg"
def test_session_title_deeper_common(tmp_path: Path) -> None:
# Two items in different subdirs share a common parent that is not "."
items = [_item("pkg/a/x.py"), _item("pkg/b/y.py")]
result = _session_title(items)
assert result == "pkg"
def test_session_title_root_files() -> None:
# Both files at root level: parent = ".", common = "."
items = [_item("a.py"), _item("b.py")]
result = _session_title(items)
assert result == "a"
# ---------------------------------------------------------------------------
# build_plan
# ---------------------------------------------------------------------------
def test_build_plan_basic(tmp_path: Path) -> None:
(tmp_path / "mod.py").write_text("def fn(): pass\n", encoding="utf-8")
items = [_item("mod.py")]
plan = build_plan(tmp_path, items)
assert plan["total_items"] == 1
assert len(plan["sessions"]) == 1
assert "source_fingerprint" in plan
def test_build_plan_session_size(tmp_path: Path) -> None:
(tmp_path / "mod.py").write_text("def fn(): pass\n", encoding="utf-8")
items = [_item("mod.py", name=f"fn{i}", start=i, end=i + 1) for i in range(5)]
plan = build_plan(tmp_path, items, session_size=2)
assert len(plan["sessions"]) == 3
def test_build_plan_empty_items(tmp_path: Path) -> None:
plan = build_plan(tmp_path, [])
assert plan["total_items"] == 0
assert plan["sessions"] == []