122 lines
4.8 KiB
Python
122 lines
4.8 KiB
Python
"""Pure-function tests for parametrize-base-branch (v1.1 task 5)."""
|
|||
|
|
|
||
|
|
import importlib.util
|
||
|
|
import json
|
||
|
|
import sys
|
||
|
|
from pathlib import Path
|
||
|
|
from unittest.mock import patch
|
||
|
|
|
||
|
|
spec = importlib.util.spec_from_file_location(
|
||
|
|
"st", str(Path(__file__).resolve().parent.parent / "scripts" / "status.py")
|
||
|
|
)
|
||
|
|
st = importlib.util.module_from_spec(spec)
|
||
|
|
spec.loader.exec_module(st)
|
||
|
|
|
||
|
|
|
||
|
|
class TestBaseBranch:
|
||
|
|
def test_default_main(self):
|
||
|
|
assert st._base_branch(None) == "main"
|
||
|
|
assert st._base_branch({}) == "main"
|
||
|
|
assert st._base_branch({"blast_radius": {}}) == "main"
|
||
|
|
assert st._base_branch({"blast_radius": {"base_branch": None}}) == "main"
|
||
|
|
|
||
|
|
def test_explicit_value(self):
|
||
|
|
assert st._base_branch({"blast_radius": {"base_branch": "trunk"}}) == "trunk"
|
||
|
|
assert st._base_branch({"blast_radius": {"base_branch": "develop"}}) == "develop"
|
||
|
|
assert st._base_branch({"blast_radius": {"base_branch": "master"}}) == "master"
|
||
|
|
|
||
|
|
def test_empty_string_with_warning(self):
|
||
|
|
assert st._base_branch({"blast_radius": {"base_branch": ""}}) == "main"
|
||
|
|
|
||
|
|
def test_non_string_with_warning(self):
|
||
|
|
assert st._base_branch({"blast_radius": {"base_branch": 42}}) == "42"
|
||
|
|
assert st._base_branch({"blast_radius": {"base_branch": True}}) == "True"
|
||
|
|
|
||
|
|
|
||
|
|
class TestDriftGateBranch:
|
||
|
|
"""Verify _gate_worktree_drift uses the configured base_branch."""
|
||
|
|
|
||
|
|
def make_state(self, worktree_path="/tmp/wt"):
|
||
|
|
return {"worktree_path": worktree_path}
|
||
|
|
|
||
|
|
def make_cfg(self, base_branch="main", file_scope=None):
|
||
|
|
fs = file_scope or ["src/", "tests/"]
|
||
|
|
cfg = {"blast_radius": {"file_scope": list(fs)}}
|
||
|
|
cfg["blast_radius"]["base_branch"] = base_branch
|
||
|
|
return cfg
|
||
|
|
|
||
|
|
@patch("subprocess.run")
|
||
|
|
def test_uses_configured_branch(self, mock_run, tmp_path):
|
||
|
|
mock_run.return_value.returncode = 0
|
||
|
|
mock_run.return_value.stdout = ""
|
||
|
|
mock_run.return_value.stderr = ""
|
||
|
|
cfg = self.make_cfg(base_branch="trunk")
|
||
|
|
st._gate_worktree_drift({"worktree_path": str(tmp_path)}, cfg, None)
|
||
|
|
call = mock_run.call_args
|
||
|
|
assert call is not None
|
||
|
|
args = call[0][0]
|
||
|
|
assert "trunk...HEAD" in args, f"expected 'trunk...HEAD' in {args}"
|
||
|
|
|
||
|
|
@patch("subprocess.run")
|
||
|
|
def test_falls_back_to_main(self, mock_run, tmp_path):
|
||
|
|
mock_run.return_value.returncode = 0
|
||
|
|
mock_run.return_value.stdout = ""
|
||
|
|
mock_run.return_value.stderr = ""
|
||
|
|
cfg = self.make_cfg(base_branch="main")
|
||
|
|
st._gate_worktree_drift({"worktree_path": str(tmp_path)}, cfg, None)
|
||
|
|
call = mock_run.call_args
|
||
|
|
args = call[0][0]
|
||
|
|
assert "main...HEAD" in args, f"expected 'main...HEAD' in {args}"
|
||
|
|
|
||
|
|
@patch("subprocess.run")
|
||
|
|
def test_bad_revision_skips_with_warning(self, mock_run, tmp_path):
|
||
|
|
mock_run.return_value.returncode = 128
|
||
|
|
mock_run.return_value.stdout = ""
|
||
|
|
mock_run.return_value.stderr = "fatal: bad revision 'not_a_branch'"
|
||
|
|
rv = st._gate_worktree_drift(
|
||
|
|
{"worktree_path": str(tmp_path)},
|
||
|
|
self.make_cfg(base_branch="not_a_branch"),
|
||
|
|
None)
|
||
|
|
assert rv is None
|
||
|
|
|
||
|
|
@patch("subprocess.run")
|
||
|
|
def test_drift_detected(self, mock_run, tmp_path):
|
||
|
|
mock_run.return_value.returncode = 0
|
||
|
|
mock_run.return_value.stdout = "src/valid.py\nextraneous.txt\n"
|
||
|
|
mock_run.return_value.stderr = ""
|
||
|
|
rv = st._gate_worktree_drift(
|
||
|
|
{"worktree_path": str(tmp_path)}, self.make_cfg(), None)
|
||
|
|
assert rv is not None
|
||
|
|
assert rv["halt_reason"] == "drift_detected"
|
||
|
|
assert "extraneous.txt" in rv["out_of_scope_files"]
|
||
|
|
|
||
|
|
@patch("subprocess.run")
|
||
|
|
def test_drift_in_scope_ok(self, mock_run, tmp_path):
|
||
|
|
mock_run.return_value.returncode = 0
|
||
|
|
mock_run.return_value.stdout = "src/valid.py\n"
|
||
|
|
mock_run.return_value.stderr = ""
|
||
|
|
rv = st._gate_worktree_drift(
|
||
|
|
{"worktree_path": str(tmp_path)}, self.make_cfg(), None)
|
||
|
|
assert rv is None
|
||
|
|
|
||
|
|
def test_no_worktree_returns_none(self):
|
||
|
|
assert st._gate_worktree_drift({}, {}, None) is None
|
||
|
|
|
||
|
|
def test_missing_worktree_dir_returns_none(self):
|
||
|
|
rv = st._gate_worktree_drift(
|
||
|
|
{"worktree_path": "/nonexistent_path_xyz"},
|
||
|
|
self.make_cfg(), None)
|
||
|
|
assert rv is None
|
||
|
|
|
||
|
|
def test_empty_file_scope_returns_none(self, tmp_path):
|
||
|
|
cfg = {"blast_radius": {"file_scope": [], "base_branch": "main"}}
|
||
|
|
rv = st._gate_worktree_drift(
|
||
|
|
{"worktree_path": str(tmp_path)}, cfg, None)
|
||
|
|
assert rv is None
|
||
|
|
|
||
|
|
def test_template_includes_base_branch(self):
|
||
|
|
tpl_path = (Path(__file__).resolve().parent.parent /
|
||
|
|
"templates" / "loops" / "self-improvement" / "loop.json")
|
||
|
|
tpl_cfg = json.loads(tpl_path.read_text())
|
||
|
|
assert tpl_cfg["blast_radius"]["base_branch"] == "main"
|