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
252 lines
8.5 KiB
Python
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
|