"""Tests for muse.core.test_selection — symbol-graph–driven test selection. Coverage: - Unit tests for every internal helper (_is_test_file, _is_test_function, _confidence, _build_coverage_map). - Integration tests for select_tests using a synthetic in-memory snapshot. - Integration tests for changed_symbols_from_diff. - Edge cases: empty diff, no test files, depth=0, fallback heuristics. """ from __future__ import annotations import pathlib import tempfile import pytest from muse.core.types import Manifest from muse.core.paths import muse_dir from muse.core.test_selection import ( ChangedSymbol, SelectionResult, SelectionTarget, _confidence, _is_test_file, _is_test_function, changed_symbols_from_diff, select_tests, ) # --------------------------------------------------------------------------- # Unit tests — _is_test_file # --------------------------------------------------------------------------- class TestIsTestFile: def test_test_prefix(self) -> None: assert _is_test_file("tests/test_foo.py") is True def test_test_suffix(self) -> None: assert _is_test_file("src/foo_test.py") is True def test_conftest(self) -> None: assert _is_test_file("tests/conftest.py") is True def test_production_file(self) -> None: assert _is_test_file("muse/core/store.py") is False def test_nested_production_file(self) -> None: # muse/cli/commands/test_cmd.py has stem "test_cmd" which starts # with "test_" — the heuristic correctly treats it as a test file. assert _is_test_file("muse/cli/commands/test_cmd.py") is True def test_non_test_stem_in_nested_path(self) -> None: assert _is_test_file("muse/cli/commands/commit.py") is False def test_empty_string(self) -> None: assert _is_test_file("") is False # --------------------------------------------------------------------------- # Unit tests — _is_test_function # --------------------------------------------------------------------------- class TestIsTestFunction: def test_test_function(self) -> None: assert _is_test_function("tests/test_foo.py::test_bar", "function") is True def test_test_method(self) -> None: assert _is_test_function("tests/test_foo.py::TestFoo::test_bar", "method") is True def test_non_test_function(self) -> None: assert _is_test_function("muse/core/store.py::read_commit", "function") is False def test_wrong_kind(self) -> None: assert _is_test_function("tests/test_foo.py::test_bar", "class") is False def test_no_double_colon(self) -> None: assert _is_test_function("tests/test_foo.py", "function") is False def test_async_function(self) -> None: assert _is_test_function("tests/test_foo.py::test_async", "async_function") is True def test_async_method(self) -> None: assert _is_test_function("tests/test_foo.py::TestClass::test_async", "async_method") is True # --------------------------------------------------------------------------- # Unit tests — _confidence # --------------------------------------------------------------------------- class TestConfidence: def test_depth_0(self) -> None: assert _confidence(0) == 1.0 def test_depth_1(self) -> None: assert _confidence(1) == 1.0 def test_depth_2(self) -> None: assert _confidence(2) == 0.9 def test_depth_3(self) -> None: assert _confidence(3) == 0.7 def test_depth_5(self) -> None: assert _confidence(5) == 0.7 # --------------------------------------------------------------------------- # Integration tests — select_tests (with real repo structure) # --------------------------------------------------------------------------- class TestSelectTests: def test_empty_changed(self, tmp_path: pathlib.Path) -> None: """Empty changed list returns an empty result with 100% coverage.""" root, manifest = _make_minimal_repo(tmp_path) result = select_tests(root, [], manifest) assert result["changed_addresses"] == [] assert result["test_targets"] == [] assert result["coverage_fraction"] == 1.0 assert result["fallback_used"] is False def test_unknown_symbol_uses_fallback(self, tmp_path: pathlib.Path) -> None: """A changed symbol with no call-graph coverage falls back to file name.""" root, manifest = _make_minimal_repo(tmp_path) changed: list[ChangedSymbol] = [ ChangedSymbol( address="prod.py::some_unknown_function", change_kind="modified", ) ] result = select_tests(root, changed, manifest) # Should still try the file-name heuristic: # "prod.py" stem "prod" → test file "tests/test_prod.py" assert result["fallback_used"] is True assert len(result["uncovered_addresses"]) == 0 or len(result["test_targets"]) >= 0 # Coverage fraction should be 0 or 1 (heuristic filled it) assert 0.0 <= result["coverage_fraction"] <= 1.0 def test_selection_result_structure(self, tmp_path: pathlib.Path) -> None: """SelectionResult has all required fields with correct types.""" root, manifest = _make_minimal_repo(tmp_path) changed: list[ChangedSymbol] = [ ChangedSymbol(address="prod.py::compute", change_kind="modified") ] result = select_tests(root, changed, manifest) assert isinstance(result["changed_addresses"], list) assert isinstance(result["test_targets"], list) assert isinstance(result["covered_addresses"], list) assert isinstance(result["uncovered_addresses"], list) assert isinstance(result["coverage_fraction"], float) assert isinstance(result["fallback_used"], bool) assert 0.0 <= result["coverage_fraction"] <= 1.0 def test_targets_have_confidence(self, tmp_path: pathlib.Path) -> None: """Every SelectionTarget has a confidence in [0.0, 1.0].""" root, manifest = _make_minimal_repo(tmp_path) changed: list[ChangedSymbol] = [ ChangedSymbol(address="prod.py::compute", change_kind="modified") ] result = select_tests(root, changed, manifest) for target in result["test_targets"]: assert 0.0 <= target["confidence"] <= 1.0 assert isinstance(target["node_id"], str) assert isinstance(target["file"], str) assert isinstance(target["reason"], str) def test_added_symbol_is_uncovered_when_no_test( self, tmp_path: pathlib.Path ) -> None: """A newly added symbol with no test shows in uncovered_addresses.""" root, manifest = _make_minimal_repo(tmp_path) changed: list[ChangedSymbol] = [ ChangedSymbol( address="prod.py::brand_new_function", change_kind="added", ) ] result = select_tests(root, changed, manifest) # The symbol has no callers → it may be uncovered OR covered by fallback assert "prod.py::brand_new_function" in result["changed_addresses"] def test_deleted_symbol_appears_in_changed( self, tmp_path: pathlib.Path ) -> None: """A deleted symbol still appears in changed_addresses.""" root, manifest = _make_minimal_repo(tmp_path) changed: list[ChangedSymbol] = [ ChangedSymbol(address="prod.py::compute", change_kind="deleted") ] result = select_tests(root, changed, manifest) assert "prod.py::compute" in result["changed_addresses"] def test_depth_cap(self, tmp_path: pathlib.Path) -> None: """depth > 10 is capped to 10 (does not raise).""" root, manifest = _make_minimal_repo(tmp_path) changed: list[ChangedSymbol] = [ ChangedSymbol(address="prod.py::compute", change_kind="modified") ] result = select_tests(root, changed, manifest, depth=50) assert isinstance(result, dict) # --------------------------------------------------------------------------- # Integration tests — changed_symbols_from_diff # --------------------------------------------------------------------------- class TestChangedSymbolsFromDiff: def test_no_change_when_workdir_matches(self, tmp_path: pathlib.Path) -> None: """When working tree matches HEAD, no changed symbols are returned.""" root, manifest = _make_minimal_repo(tmp_path) result = changed_symbols_from_diff(root, manifest) # Working tree files are the same as in the object store → no changes. # (Since _make_minimal_repo writes the files both as objects and to disk) assert isinstance(result, list) def test_returns_list_of_changed_symbols(self, tmp_path: pathlib.Path) -> None: """Return type is always list[ChangedSymbol].""" root, manifest = _make_minimal_repo(tmp_path) result = changed_symbols_from_diff(root, manifest) for item in result: assert "address" in item assert "change_kind" in item assert item["change_kind"] in {"modified", "added", "deleted"} def test_modified_file_produces_changed(self, tmp_path: pathlib.Path) -> None: """Editing a file on disk produces 'modified' entries in the diff.""" root, manifest = _make_minimal_repo(tmp_path) # Write a different version of prod.py to disk. new_src = b"""\ def compute(x: int) -> int: # Extra line to make this body different return x * 2 + 0 def helper() -> int: return 42 """ (root / "prod.py").write_bytes(new_src) result = changed_symbols_from_diff(root, manifest) addresses = [c["address"] for c in result] # "compute" has a different body now → should appear modified_addrs = [ c["address"] for c in result if c["change_kind"] == "modified" ] assert any("compute" in addr for addr in modified_addrs) def test_new_file_produces_added(self, tmp_path: pathlib.Path) -> None: """A new file on disk (not in manifest) is not in result (manifest-based).""" root, manifest = _make_minimal_repo(tmp_path) # Write a new file that isn't in the manifest — should be invisible. (root / "new_file.py").write_bytes(b"def new_func() -> None: pass\n") result = changed_symbols_from_diff(root, manifest) addresses = [c["address"] for c in result] assert not any("new_file.py" in addr for addr in addresses) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_minimal_repo( tmp_path: pathlib.Path, ) -> tuple[pathlib.Path, dict[str, str]]: """Create a minimal .muse repo with prod.py and tests/test_prod.py.""" from muse.core.object_store import write_object dot_muse = muse_dir(tmp_path) dot_muse.mkdir(exist_ok=True) prod_src = b"""\ def compute(x: int) -> int: return x * 2 def helper() -> int: return 42 """ test_src = b"""\ from prod import compute def test_compute() -> None: assert compute(2) == 4 """ from muse.core.types import blob_id prod_oid = blob_id(prod_src) test_oid = blob_id(test_src) write_object(tmp_path, prod_oid, prod_src) write_object(tmp_path, test_oid, test_src) # Also write to disk so working-tree diff can read them. (tmp_path / "prod.py").write_bytes(prod_src) tests_dir = tmp_path / "tests" tests_dir.mkdir(exist_ok=True) (tests_dir / "test_prod.py").write_bytes(test_src) manifest: Manifest = { "prod.py": prod_oid, "tests/test_prod.py": test_oid, } return tmp_path, manifest