""" Tests for the _commit_analysis pre-commit hook shared module. Covers: classify_file, suggest_test_path, _parse_numstat, CommitAnalysis dataclass, and analyze_commit (with mocked git). """ import sys from pathlib import Path from unittest.mock import patch # Add the pre-commit hooks directory to path HOOKS_DIR = Path(__file__).parent.parent.parent / ".pre-commit-hooks" sys.path.insert(0, str(HOOKS_DIR)) from _commit_analysis import ( # noqa: E402 CommitAnalysis, StagedFileInfo, _parse_numstat, analyze_commit, classify_file, suggest_test_path, ) # ========================================================================= # classify_file tests # ========================================================================= class TestClassifyFile: """Tests for classify_file path classification.""" # --- Source files --- def test_source_file_under_src(self): assert classify_file("src/local_deep_research/web/api.py") == "source" def test_source_file_deep_nesting(self): assert ( classify_file( "src/local_deep_research/web_search_engines/engines/search_engine_google.py" ) == "source" ) def test_source_file_direct_under_src(self): assert classify_file("src/local_deep_research/main.py") == "source" # --- Test files --- def test_test_file_under_tests_dir(self): assert classify_file("tests/web/test_api.py") == "test" def test_test_file_by_prefix(self): assert classify_file("src/test_something.py") == "test" def test_test_file_by_suffix(self): assert classify_file("src/something_test.py") == "test" def test_test_file_nested_in_tests(self): assert classify_file("tests/security/test_url_validator.py") == "test" # --- Doc files --- def test_markdown_file(self): assert classify_file("README.md") == "doc" def test_markdown_file_in_docs(self): assert classify_file("docs/setup.md") == "doc" def test_markdown_file_nested(self): assert classify_file("src/local_deep_research/CHANGELOG.md") == "doc" # --- Excluded directories --- def test_migrations_dir(self): assert ( classify_file("src/local_deep_research/migrations/001.py") == "migration" ) def test_alembic_dir(self): assert classify_file("alembic/versions/abc123.py") == "migration" def test_config_dir(self): assert ( classify_file("src/local_deep_research/config/settings.py") == "config" ) def test_settings_dir(self): assert classify_file("settings/defaults.py") == "config" def test_scripts_dir(self): assert classify_file("scripts/dev/restart_server.py") == "script" def test_pre_commit_hooks_dir(self): assert classify_file(".pre-commit-hooks/check-env-vars.py") == "hook" def test_examples_dir(self): assert classify_file("examples/basic_usage.py") == "other" def test_docs_dir_python_file(self): assert classify_file("docs/conf.py") == "doc" # --- Special basenames --- def test_init_py(self): assert classify_file("src/local_deep_research/__init__.py") == "init" def test_conftest_py(self): assert classify_file("tests/conftest.py") == "conftest" # --- Non-Python files --- def test_json_file(self): assert ( classify_file( "src/local_deep_research/defaults/default_settings.json" ) == "other" ) def test_yaml_file(self): assert classify_file(".pre-commit-config.yaml") == "other" def test_html_file(self): assert ( classify_file("src/local_deep_research/web/templates/index.html") == "other" ) def test_javascript_file(self): assert ( classify_file("src/local_deep_research/web/static/app.js") == "other" ) # --- Edge cases --- def test_top_level_python_file(self): assert classify_file("setup.py") == "other" def test_empty_path_components(self): """Files not under src/ and not matching other patterns are 'other'.""" assert classify_file("lib/helper.py") == "other" def test_case_insensitive_excluded_dirs(self): """Excluded dirs match case-insensitively.""" assert ( classify_file("src/local_deep_research/Config/app.py") == "config" ) def test_test_prefix_not_in_tests_dir(self): """test_*.py files are classified as test even outside tests/.""" assert classify_file("src/test_helpers.py") == "test" # ========================================================================= # suggest_test_path tests # ========================================================================= class TestSuggestTestPath: """Tests for suggest_test_path path generation.""" def test_standard_source_file(self): result = suggest_test_path("src/local_deep_research/web/api.py") assert result == "tests/web/test_api.py" def test_deeply_nested_source(self): result = suggest_test_path( "src/local_deep_research/web_search_engines/engines/search_engine_google.py" ) assert ( result == "tests/web_search_engines/engines/test_search_engine_google.py" ) def test_direct_under_package(self): result = suggest_test_path("src/local_deep_research/main.py") assert result == "tests/test_main.py" def test_strips_src_prefix(self): """Should strip src/ prefix from path.""" result = suggest_test_path("src/local_deep_research/utils.py") assert result.startswith("tests/") assert "src" not in result def test_adds_test_prefix_to_filename(self): """Should add test_ prefix to the filename.""" result = suggest_test_path("src/local_deep_research/database/models.py") assert result.endswith("test_models.py") # ========================================================================= # _parse_numstat tests # ========================================================================= class TestParseNumstat: """Tests for _parse_numstat git output parsing.""" def test_normal_output(self): lines = [ "10\t5\tsrc/local_deep_research/web/api.py", "20\t3\ttests/web/test_api.py", ] result = _parse_numstat(lines) assert result == { "src/local_deep_research/web/api.py": (10, 5), "tests/web/test_api.py": (20, 3), } def test_binary_file(self): """Binary files show '-' for added/removed counts.""" lines = ["-\t-\timages/logo.png"] result = _parse_numstat(lines) assert result == {"images/logo.png": (0, 0)} def test_empty_input(self): assert _parse_numstat([]) == {} def test_malformed_line_skipped(self): """Lines with wrong number of fields are skipped.""" lines = [ "10\t5\tsrc/api.py", "bad line", "20\t3\ttests/test_api.py", ] result = _parse_numstat(lines) assert len(result) == 2 assert "src/api.py" in result assert "tests/test_api.py" in result def test_zero_counts(self): lines = ["0\t0\tempty_file.py"] result = _parse_numstat(lines) assert result == {"empty_file.py": (0, 0)} def test_large_counts(self): lines = ["9999\t8888\tbig_file.py"] result = _parse_numstat(lines) assert result == {"big_file.py": (9999, 8888)} def test_filepath_with_spaces(self): """Tabs separate fields, so filenames with spaces work.""" lines = ["5\t2\tpath with spaces/file.py"] result = _parse_numstat(lines) assert "path with spaces/file.py" in result # ========================================================================= # CommitAnalysis dataclass tests # ========================================================================= class TestCommitAnalysis: """Tests for CommitAnalysis computed properties.""" def _make_file( self, path="src/a.py", added=10, removed=0, is_new=False, category="source", ): return StagedFileInfo( path=path, added=added, removed=removed, is_new=is_new, category=category, ) def test_empty_analysis(self): analysis = CommitAnalysis() assert analysis.total_source_added == 0 assert analysis.has_tests is False assert analysis.has_docs is False assert analysis.new_source_files == [] def test_total_source_added(self): analysis = CommitAnalysis( source_files=[ self._make_file(added=10), self._make_file(added=20), ] ) assert analysis.total_source_added == 30 def test_has_tests_true(self): analysis = CommitAnalysis(test_files=[self._make_file(category="test")]) assert analysis.has_tests is True def test_has_tests_false(self): analysis = CommitAnalysis(source_files=[self._make_file()]) assert analysis.has_tests is False def test_has_docs_true(self): analysis = CommitAnalysis(doc_files=[self._make_file(category="doc")]) assert analysis.has_docs is True def test_new_source_files(self): analysis = CommitAnalysis( source_files=[ self._make_file(path="a.py", is_new=True), self._make_file(path="b.py", is_new=False), self._make_file(path="c.py", is_new=True), ] ) new = analysis.new_source_files assert len(new) == 2 assert all(f.is_new for f in new) # ========================================================================= # analyze_commit integration tests (with mocked git) # ========================================================================= class TestAnalyzeCommit: """Tests for analyze_commit with mocked git commands.""" def _mock_run_git(self, name_status_lines, numstat_lines): """Create a mock for _run_git returning different output per command.""" def side_effect(args): if "--name-status" in args: return name_status_lines if "--numstat" in args: return numstat_lines return [] return side_effect def test_basic_source_and_test_commit(self): mock = self._mock_run_git( name_status_lines=[ "M\tsrc/local_deep_research/web/api.py", "A\ttests/web/test_api.py", ], numstat_lines=[ "15\t3\tsrc/local_deep_research/web/api.py", "50\t0\ttests/web/test_api.py", ], ) with patch("_commit_analysis._run_git", side_effect=mock): analysis = analyze_commit() assert len(analysis.source_files) == 1 assert len(analysis.test_files) == 1 assert ( analysis.source_files[0].path == "src/local_deep_research/web/api.py" ) assert analysis.source_files[0].is_new is False assert analysis.test_files[0].is_new is True assert analysis.has_tests is True def test_new_file_detection(self): mock = self._mock_run_git( name_status_lines=["A\tsrc/local_deep_research/new_module.py"], numstat_lines=["100\t0\tsrc/local_deep_research/new_module.py"], ) with patch("_commit_analysis._run_git", side_effect=mock): analysis = analyze_commit() assert len(analysis.source_files) == 1 assert analysis.source_files[0].is_new is True assert analysis.source_files[0].added == 100 def test_doc_files_classified(self): mock = self._mock_run_git( name_status_lines=["A\tREADME.md"], numstat_lines=["20\t0\tREADME.md"], ) with patch("_commit_analysis._run_git", side_effect=mock): analysis = analyze_commit() assert len(analysis.doc_files) == 1 assert analysis.has_docs is True def test_excluded_files_not_in_any_list(self): """Config, migration, and other excluded files don't appear in analysis lists.""" mock = self._mock_run_git( name_status_lines=[ "M\tsrc/local_deep_research/config/settings.py", "M\t.pre-commit-config.yaml", ], numstat_lines=[ "5\t2\tsrc/local_deep_research/config/settings.py", "3\t1\t.pre-commit-config.yaml", ], ) with patch("_commit_analysis._run_git", side_effect=mock): analysis = analyze_commit() assert len(analysis.source_files) == 0 assert len(analysis.test_files) == 0 assert len(analysis.doc_files) == 0 def test_empty_commit(self): mock = self._mock_run_git( name_status_lines=[], numstat_lines=[], ) with patch("_commit_analysis._run_git", side_effect=mock): analysis = analyze_commit() assert len(analysis.source_files) == 0 assert analysis.total_source_added == 0 assert analysis.has_tests is False assert analysis.has_docs is False def test_mixed_commit_with_all_types(self): mock = self._mock_run_git( name_status_lines=[ "A\tsrc/local_deep_research/feature.py", "A\ttests/test_feature.py", "M\tREADME.md", "M\tscripts/dev/helper.py", ], numstat_lines=[ "80\t0\tsrc/local_deep_research/feature.py", "40\t0\ttests/test_feature.py", "10\t5\tREADME.md", "3\t1\tscripts/dev/helper.py", ], ) with patch("_commit_analysis._run_git", side_effect=mock): analysis = analyze_commit() assert len(analysis.source_files) == 1 assert len(analysis.test_files) == 1 assert len(analysis.doc_files) == 1 assert analysis.total_source_added == 80 assert analysis.has_tests is True assert analysis.has_docs is True assert analysis.source_files[0].is_new is True