Files
stack/tests/sem/test_hooks.py
kert 59eb56f659
Some checks failed
CI / skinny-install (aco) (push) Successful in 1m18s
CI / skinny-install (api) (push) Successful in 40s
CI / skinny-install (bcda) (push) Successful in 35s
CI / skinny-install (bib) (push) Successful in 38s
CI / skinny-install (cli) (push) Successful in 46s
CI / skinny-install (conf) (push) Successful in 36s
CI / skinny-install (opps) (push) Successful in 38s
CI / skinny-install (pfs) (push) Successful in 47s
CI / skinny-install (rex) (push) Successful in 35s
Infra CI / notebooks (push) Successful in 3m17s
CI / lint-test (push) Failing after 3m30s
CI / skinny-install (bls) (push) Successful in 34s
CI / skinny-install (ccw) (push) Successful in 45s
CI / skinny-install (cms) (push) Successful in 32s
CI / skinny-install (perf) (push) Successful in 43s
Deploy / build-scan-report (push) Has been cancelled
Infra CI / docs (push) Failing after 20s
Infra CI / api (push) Successful in 16s
Infra CI / mc (push) Successful in 12s
Package Supply Chain / pkg-supply-chain (push) Successful in 1m27s
Infra CI / zotero (push) Successful in 6m10s
chore: hw provisioning, test coverage, deps
2026-04-09 22:26:31 -04:00

252 lines
8.5 KiB
Python

"""Tests for sem.hooks — pre-commit smart test selection."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from sem.hooks import (
_changed_modules,
_changed_test_dirs,
_classify,
_staged_files,
_top_level_tests,
check_syntax,
compute_test_targets,
main,
run_step,
)
class TestClassify:
def test_src_files(self):
cats = _classify(["src/aco/express/foo.py", "src/sem/parse.py"])
assert cats["src"] == ["src/aco/express/foo.py", "src/sem/parse.py"]
assert cats["tests"] == []
def test_test_files(self):
cats = _classify(["tests/aco/test_foo.py"])
assert cats["tests"] == ["tests/aco/test_foo.py"]
def test_notebooks(self):
cats = _classify(["notebooks/pfs_calcs.py"])
assert cats["notebooks"] == ["notebooks/pfs_calcs.py"]
def test_infra_conftest(self):
cats = _classify(["tests/conftest.py"])
assert "tests/conftest.py" in cats["infra"]
def test_infra_pyproject(self):
cats = _classify(["pyproject.toml"])
assert "pyproject.toml" in cats["infra"]
def test_config_stack_toml(self):
cats = _classify(["stack.toml"])
assert cats["config"] == ["stack.toml"]
def test_non_python_ignored(self):
cats = _classify(["README.md", "compose.yml", "src/aco/data.json"])
assert all(not v for v in cats.values())
class TestChangedModules:
def test_extracts_top_level(self):
assert _changed_modules(["src/aco/express/foo.py"]) == ["aco"]
assert _changed_modules(["src/sem/parse.py"]) == ["sem"]
def test_deduplicates(self):
result = _changed_modules(
[
"src/aco/express/foo.py",
"src/aco/pipe/bar.py",
]
)
assert result == ["aco"]
def test_multiple_modules(self):
result = _changed_modules(
[
"src/aco/foo.py",
"src/sem/bar.py",
"src/bib/baz.py",
]
)
assert result == ["aco", "bib", "sem"]
class TestChangedTestDirs:
def test_extracts_dirs(self):
result = _changed_test_dirs(["tests/aco/test_foo.py"])
assert result == ["tests/aco"]
def test_deduplicates(self):
result = _changed_test_dirs(
[
"tests/aco/test_a.py",
"tests/aco/test_b.py",
]
)
assert result == ["tests/aco"]
class TestTopLevelTests:
def test_finds_top_level(self):
result = _top_level_tests(
[
"tests/test_ast_coverage.py",
"tests/aco/test_foo.py",
]
)
assert result == ["tests/test_ast_coverage.py"]
class TestCheckSyntax:
def test_valid_file(self, tmp_path):
f = tmp_path / "good.py"
f.write_text("x = 1\n")
assert check_syntax([str(f)]) == []
def test_invalid_file(self, tmp_path):
f = tmp_path / "bad.py"
f.write_text("def f(\n")
errors = check_syntax([str(f)])
assert len(errors) == 1
assert "bad.py" in errors[0]
def test_missing_file_skipped(self):
assert check_syntax(["nonexistent_file_xyz.py"]) == []
class TestComputeTargets:
def test_full_on_infra_change(self):
cats = _classify(["pyproject.toml", "src/sem/parse.py"])
targets, reason = compute_test_targets(cats)
assert targets == ["tests/"]
assert "infrastructure" in reason
def test_full_on_force(self):
cats = _classify(["src/sem/parse.py"])
targets, reason = compute_test_targets(cats, force_full=True)
assert targets == ["tests/"]
def test_targeted_on_src_change(self):
cats = _classify(["src/sem/parse.py"])
targets, reason = compute_test_targets(cats)
# Should include tests/sem/ and test_ast_coverage.py
assert any("tests/sem/" in t for t in targets)
assert any("test_ast_coverage" in t for t in targets)
def test_empty_on_no_python(self):
cats = _classify(["README.md"])
targets, reason = compute_test_targets(cats)
assert targets == []
assert "no testable" in reason
def test_test_file_changes_add_test_dirs(self):
"""Changed test files add their parent dir to targets."""
cats = _classify(["tests/aco/test_foo.py", "tests/bib/test_bar.py"])
targets, reason = compute_test_targets(cats)
assert any("tests/aco" in t for t in targets)
assert any("tests/bib" in t for t in targets)
def test_top_level_test_file_added(self):
"""Top-level test files are added directly."""
cats = _classify(["tests/test_something.py"])
targets, reason = compute_test_targets(cats)
assert "tests/test_something.py" in targets
def test_deduplication(self):
"""Targets from src and test don't duplicate."""
cats = _classify(["src/aco/foo.py", "tests/aco/test_foo.py"])
targets, reason = compute_test_targets(cats)
aco_targets = [t for t in targets if "aco" in t]
assert len(aco_targets) == 1
class TestStagedFiles:
@patch("sem.hooks.subprocess.run")
def test_parses_git_output(self, mock_run):
mock_run.return_value = MagicMock(
stdout="src/aco/foo.py\ntests/aco/test_foo.py\n",
returncode=0,
)
result = _staged_files()
assert result == ["src/aco/foo.py", "tests/aco/test_foo.py"]
@patch("sem.hooks.subprocess.run")
def test_empty_output(self, mock_run):
mock_run.return_value = MagicMock(stdout="", returncode=0)
result = _staged_files()
assert result == []
@patch("sem.hooks.subprocess.run")
def test_filters_empty_lines(self, mock_run):
mock_run.return_value = MagicMock(stdout="foo.py\n\nbar.py\n", returncode=0)
result = _staged_files()
assert result == ["foo.py", "bar.py"]
class TestRunStep:
def test_returns_exit_code(self):
rc = run_step("echo test", ["python", "-c", "pass"])
assert rc == 0
def test_nonzero_exit_code(self):
rc = run_step("fail", ["python", "-c", "import sys; sys.exit(1)"])
assert rc == 1
class TestMain:
@patch("sem.hooks._staged_files", return_value=[])
def test_no_staged_files_returns_zero(self, mock_staged):
assert main() == 0
@patch("sem.hooks.run_step", return_value=0)
@patch("sem.hooks.subprocess.run")
@patch("sem.hooks._staged_files", return_value=["src/aco/foo.py"])
def test_src_change_runs_lint_and_pytest(
self, mock_staged, mock_subproc, mock_step
):
mock_subproc.return_value = MagicMock(returncode=0)
result = main()
assert result == 0
# Should have called run_step for ruff check, format, and pytest
step_labels = [c[0][0] for c in mock_step.call_args_list]
assert any("ruff check" in l for l in step_labels)
@patch("sem.hooks.run_step", return_value=0)
@patch("sem.hooks.subprocess.run")
@patch("sem.hooks._staged_files", return_value=["stack.toml"])
def test_config_change_triggers_regen(self, mock_staged, mock_subproc, mock_step):
mock_subproc.return_value = MagicMock(returncode=0)
result = main()
assert result == 0
step_labels = [c[0][0] for c in mock_step.call_args_list]
assert any("config" in l or "regen" in l for l in step_labels)
@patch("sem.hooks.run_step")
@patch("sem.hooks.subprocess.run")
@patch("sem.hooks._staged_files", return_value=["src/aco/foo.py"])
def test_lint_failure_aborts(self, mock_staged, mock_subproc, mock_step):
mock_subproc.return_value = MagicMock(returncode=0)
mock_step.return_value = 1 # lint fails
result = main()
assert result == 1
@patch("sem.hooks.run_step", return_value=0)
@patch("sem.hooks.subprocess.run")
@patch("sem.hooks._staged_files", return_value=["notebooks/pfs_calcs.py"])
def test_notebook_change_runs_marimo(self, mock_staged, mock_subproc, mock_step):
mock_subproc.return_value = MagicMock(returncode=0)
result = main()
assert result == 0
step_labels = [c[0][0] for c in mock_step.call_args_list]
assert any("marimo" in l or "notebook" in l for l in step_labels)
@patch("sem.hooks.run_step", return_value=0)
@patch("sem.hooks.subprocess.run")
@patch("sem.hooks._staged_files", return_value=["README.md"])
def test_non_python_change_skips_tests(self, mock_staged, mock_subproc, mock_step):
mock_subproc.return_value = MagicMock(returncode=0)
result = main()
assert result == 0