mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 00:07:03 +01:00
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
178 lines
5.5 KiB
Python
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"] == []
|