mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 00:07:03 +01:00
_challenge_support.py 402 -> 237. _pytest_clean and _patch_and_test go to _pytest_runner.py with the subprocess/os/sys imports they solely used; _stream_verdict and _parse_verdict go to _verdict.py with rich's Live. code_tutor patches by absolute string rather than a MOD constant, so the seam cost 15 per-name rewrites instead of one sed. Two intermediate failures caught by running it: the test modules import the moved names directly, and five patch strings still named the old module. Pass count is 216 before and after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
344 lines
12 KiB
Python
344 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,
|
|
_project_root,
|
|
_scan_test_file,
|
|
_show_test_panels,
|
|
)
|
|
from python_pkg.code_tutor._pytest_runner import _patch_and_test, _pytest_clean
|
|
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._pytest_runner.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._pytest_runner.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._pytest_runner.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._pytest_runner._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._pytest_runner._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
|