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

202 lines
6.4 KiB
Python

"""Tests for python_pkg.code_tutor._session."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.code_tutor._progress import LessonRecord
from python_pkg.code_tutor._session import _items_from_plan, _show_summary, run_session
if TYPE_CHECKING:
from pathlib import Path
def _make_item_data(name: str = "fn", file: str = "mod.py") -> dict:
return {
"id": f"{file}.{name}",
"file": file,
"type": "function",
"name": name,
"start_line": 1,
"end_line": 5,
"class_name": "",
"depends_on": [],
}
def _make_plan(items: list[dict]) -> dict:
return {
"codebase_path": "/cb",
"created_at": "2026-01-01T00:00:00+00:00",
"total_items": len(items),
"source_fingerprint": "abc",
"sessions": [{"id": 1, "title": "mod", "items": items}],
}
def _make_record(item_id: str, outcome: str) -> LessonRecord:
return LessonRecord(
timestamp="2026-01-01T00:00:00+00:00",
item_id=item_id,
file="mod.py",
lines="1-5",
snippet="code",
outcome=outcome,
answers={},
improvement="",
verdict="PASS" if outcome == "learned" else "FAIL",
attempt=1,
)
# ---------------------------------------------------------------------------
# _items_from_plan
# ---------------------------------------------------------------------------
def test_items_from_plan_basic() -> None:
plan = _make_plan([_make_item_data("fn")])
items = _items_from_plan(plan)
assert len(items) == 1
assert items[0].name == "fn"
def test_items_from_plan_multiple_sessions() -> None:
plan = {
"codebase_path": "/cb",
"created_at": "t",
"total_items": 2,
"source_fingerprint": "x",
"sessions": [
{"id": 1, "title": "a", "items": [_make_item_data("fn1")]},
{"id": 2, "title": "b", "items": [_make_item_data("fn2")]},
],
}
items = _items_from_plan(plan)
assert len(items) == 2
# ---------------------------------------------------------------------------
# _show_summary
# ---------------------------------------------------------------------------
def test_show_summary_renders_table() -> None:
mock_console = MagicMock()
_show_summary(
{"learned": ["a", "b"], "struggled": ["c"], "skipped": []},
mock_console,
)
mock_console.print.assert_called_once()
def test_show_summary_empty() -> None:
mock_console = MagicMock()
_show_summary({}, mock_console)
mock_console.print.assert_called_once()
# ---------------------------------------------------------------------------
# run_session
# ---------------------------------------------------------------------------
def test_run_session_no_plan(tmp_path: Path) -> None:
mock_backend = MagicMock()
mock_console = MagicMock()
with patch("python_pkg.code_tutor._session.load_plan", return_value=None):
run_session(tmp_path, mock_backend, console=mock_console)
mock_console.print.assert_called()
def test_run_session_all_done(tmp_path: Path) -> None:
plan = _make_plan([_make_item_data("fn")])
progress = {
"learned": ["mod.py.fn"],
"struggled": [],
"skipped": [],
"last_session": "",
}
mock_backend = MagicMock()
mock_console = MagicMock()
with (
patch("python_pkg.code_tutor._session.load_plan", return_value=plan),
patch("python_pkg.code_tutor._session.load_progress", return_value=progress),
):
run_session(tmp_path, mock_backend, console=mock_console)
mock_console.print.assert_called()
def test_run_session_learned(tmp_path: Path) -> None:
item_data = _make_item_data("fn")
plan = _make_plan([item_data])
progress = {"learned": [], "struggled": [], "skipped": [], "last_session": ""}
record = _make_record("mod.py.fn", "learned")
mock_backend = MagicMock()
mock_console = MagicMock()
mock_verifier = MagicMock()
mock_verifier.run_lesson.return_value = record
with (
patch("python_pkg.code_tutor._session.load_plan", return_value=plan),
patch("python_pkg.code_tutor._session.load_progress", return_value=progress),
patch("python_pkg.code_tutor._session.Verifier", return_value=mock_verifier),
patch("python_pkg.code_tutor._session.append_session_record"),
patch("python_pkg.code_tutor._session.save_progress"),
):
run_session(tmp_path, mock_backend, console=mock_console)
assert "mod.py.fn" in progress["learned"]
def test_run_session_struggled(tmp_path: Path) -> None:
item_data = _make_item_data("fn")
plan = _make_plan([item_data])
progress = {"learned": [], "struggled": [], "skipped": [], "last_session": ""}
record = _make_record("mod.py.fn", "struggled")
mock_backend = MagicMock()
mock_console = MagicMock()
mock_verifier = MagicMock()
mock_verifier.run_lesson.return_value = record
with (
patch("python_pkg.code_tutor._session.load_plan", return_value=plan),
patch("python_pkg.code_tutor._session.load_progress", return_value=progress),
patch("python_pkg.code_tutor._session.Verifier", return_value=mock_verifier),
patch("python_pkg.code_tutor._session.append_session_record"),
patch("python_pkg.code_tutor._session.save_progress"),
):
run_session(tmp_path, mock_backend, console=mock_console)
assert "mod.py.fn" in progress["struggled"]
def test_run_session_skipped(tmp_path: Path) -> None:
item_data = _make_item_data("fn")
plan = _make_plan([item_data])
progress = {"learned": [], "struggled": [], "skipped": [], "last_session": ""}
record = _make_record("mod.py.fn", "skipped")
mock_backend = MagicMock()
mock_console = MagicMock()
mock_verifier = MagicMock()
mock_verifier.run_lesson.return_value = record
with (
patch("python_pkg.code_tutor._session.load_plan", return_value=plan),
patch("python_pkg.code_tutor._session.load_progress", return_value=progress),
patch("python_pkg.code_tutor._session.Verifier", return_value=mock_verifier),
patch("python_pkg.code_tutor._session.append_session_record"),
patch("python_pkg.code_tutor._session.save_progress"),
):
run_session(tmp_path, mock_backend, console=mock_console)
assert "mod.py.fn" in progress["skipped"]