diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 27ab1fdd..9f0fd244 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: - id: trailing-whitespace - id: name-tests-test - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.10 + rev: v0.14.14 hooks: - id: ruff-check args: [ --fix ] @@ -31,7 +31,7 @@ repos: hooks: - id: codespell - repo: https://github.com/commit-check/commit-check - rev: v2.2.1 + rev: v2.2.2 hooks: - id: check-message stages: [commit-msg] diff --git a/cchk.toml b/cchk.toml index 431612ef..275aafb6 100644 --- a/cchk.toml +++ b/cchk.toml @@ -18,6 +18,6 @@ ignore_authors = ["dependabot[bot]", "copilot[bot]", "pre-commit-ci[bot]"] [branch] # https://conventional-branch.github.io/ conventional_branch = true -allow_branch_types = ["feature", "bugfix", "hotfix", "release", "chore", "feat", "fix"] +allow_branch_types = ["feature", "bugfix", "hotfix", "release", "chore", "feat", "fix", "copilot"] require_rebase_target = "main" ignore_authors = ["dependabot[bot]", "copilot[bot]", "pre-commit-ci[bot]", "shenxianpeng"] diff --git a/commit_check/util.py b/commit_check/util.py index 1b4c1e72..b725025a 100644 --- a/commit_check/util.py +++ b/commit_check/util.py @@ -24,7 +24,7 @@ _toml = None # type: ignore[assignment] -def _find_check(checks: list, check_type: str) -> dict | None: +def _find_check(checks: list, check_type: str) -> Optional[dict]: """Return the first check dict matching check_type, else None.""" for check in checks: if check.get("check") == check_type: @@ -254,7 +254,7 @@ def print_error_message(check_type: str, regex: str, error: str, reason: str): print(error) -def print_suggestion(suggest: str | None) -> None: +def print_suggestion(suggest: Optional[str]) -> None: """Print suggestion to user :param suggest: what message to print out """ diff --git a/tests/config_edge_test.py b/tests/config_edge_test.py deleted file mode 100644 index ccafb4df..00000000 --- a/tests/config_edge_test.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Test for TOML parsing errors and exception handling.""" - -import pytest -import tempfile -import os -from commit_check.config import load_config - - -@pytest.mark.benchmark -def test_load_config_invalid_toml(): - """Test handling of invalid TOML syntax.""" - invalid_toml = b""" -[incomplete -missing closing bracket -""" - with tempfile.NamedTemporaryFile(mode="wb", suffix=".toml", delete=False) as f: - f.write(invalid_toml) - f.flush() - - try: - with pytest.raises(Exception): # Should raise a TOML parsing error - load_config(f.name) - finally: - os.unlink(f.name) - - -@pytest.mark.benchmark -def test_load_config_file_permission_error(): - """Test handling of file permission errors.""" - config_content = b""" -[checks] -test = true -""" - with tempfile.NamedTemporaryFile(mode="wb", suffix=".toml", delete=False) as f: - f.write(config_content) - f.flush() - - try: - # Remove read permissions to simulate permission error - os.chmod(f.name, 0o000) - - with pytest.raises(PermissionError): - load_config(f.name) - finally: - # Restore permissions and clean up - os.chmod(f.name, 0o644) - os.unlink(f.name) - - -@pytest.mark.benchmark -def test_tomli_import_fallback(): - """Test the tomli import fallback when tomllib is not available.""" - # We need to test the import fallback behavior - # This is complex because the imports happen at module load time - - # Create a minimal test by importing the config module's behavior - config_content = b""" -[test] -fallback = true -""" - - with tempfile.NamedTemporaryFile(mode="wb", suffix=".toml", delete=False) as f: - f.write(config_content) - f.flush() - - try: - # Test that we can load config regardless of which TOML library is used - config = load_config(f.name) - assert config["test"]["fallback"] is True - - # Since we can't easily test the import fallback on Python 3.11+, - # let's at least verify that the toml_load function works - from commit_check.config import toml_load - - with open(f.name, "rb") as config_file: - result = toml_load(config_file) - assert result["test"]["fallback"] is True - - finally: - os.unlink(f.name) diff --git a/tests/config_fallback_test.py b/tests/config_fallback_test.py deleted file mode 100644 index 0c6a8e24..00000000 --- a/tests/config_fallback_test.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Direct test of the config import fallback using module manipulation.""" - -import sys -import tempfile -import os -import pytest -from unittest.mock import patch - - -@pytest.mark.benchmark -def test_config_tomli_fallback_direct(): - """Test config.py fallback to tomli by manipulating imports.""" - - # Save original state - original_modules = sys.modules.copy() - - try: - # Remove config module if already imported - if "commit_check.config" in sys.modules: - del sys.modules["commit_check.config"] - - # Make tomllib unavailable by raising ImportError - original_import = __import__ - - def mock_import(name, globals=None, locals=None, fromlist=(), level=0): - if name == "tomllib": - raise ImportError("No module named 'tomllib'") - # For tomli, return a working mock - if name == "tomli": - - class MockTomli: - @staticmethod - def load(f): - content = f.read().decode("utf-8") - # Simple parser for test - if 'test_key = "test_value"' in content: - return {"test_key": "test_value"} - return {} - - return MockTomli() - return original_import(name, globals, locals, fromlist, level) - - with patch("builtins.__import__", side_effect=mock_import): - # Now import config - should use tomli fallback - import commit_check.config as config - - # Test that it works - config_content = b'test_key = "test_value"' - with tempfile.NamedTemporaryFile( - mode="wb", suffix=".toml", delete=False - ) as f: - f.write(config_content) - f.flush() - - try: - with open(f.name, "rb") as config_file: - result = config.toml_load(config_file) - assert result == {"test_key": "test_value"} - finally: - os.unlink(f.name) - - finally: - # Restore original modules - sys.modules.clear() - sys.modules.update(original_modules) diff --git a/tests/config_import_test.py b/tests/config_import_test.py deleted file mode 100644 index 083e84fb..00000000 --- a/tests/config_import_test.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Test import fallback by creating a test version of config.py.""" - -import tempfile -import os -from unittest.mock import patch -import pytest - - -@pytest.mark.benchmark -def test_tomli_import_fallback_simulation(): - """Test tomli import fallback by simulating the ImportError condition.""" - - # Create test code that simulates the config.py import logic - test_code = """ -try: - import tomllib - toml_load = tomllib.load - used_tomllib = True -except ImportError: - import tomli - toml_load = tomli.load - used_tomllib = False -""" - - # Test case 1: Normal case (tomllib available) - namespace1 = {} - exec(test_code, namespace1) - assert namespace1["used_tomllib"] is True - assert callable(namespace1["toml_load"]) - - # Test case 2: Simulate ImportError for tomllib - with patch.dict("sys.modules", {"tomllib": None}): - with patch( - "builtins.__import__", - side_effect=lambda name, *args, **kwargs: _mock_import_error( - name, *args, **kwargs - ), - ): - namespace2 = {} - exec(test_code, namespace2) - assert namespace2["used_tomllib"] is False - assert callable(namespace2["toml_load"]) - - -def _mock_import_error(name, *args, **kwargs): - """Mock import function that raises ImportError for tomllib.""" - if name == "tomllib": - raise ImportError("No module named 'tomllib'") - - # For tomli, we need to mock it since it might not be installed - if name == "tomli": - # Create a mock tomli module - class MockTomli: - @staticmethod - def load(f): - # Simple TOML parser for testing - content = f.read().decode("utf-8") - if "[test]" in content and 'value = "test"' in content: - return {"test": {"value": "test"}} - return {} - - return MockTomli() - - # For all other imports, use the real import - return __import__(name, *args, **kwargs) - - -@pytest.mark.benchmark -def test_import_paths_coverage(): - """Ensure both import paths are conceptually tested.""" - # This test verifies that both the tomllib and tomli code paths - # would work in their respective environments - - # Test the function signature matches expectation - config_content = b""" -[test] -value = "test" -""" - - with tempfile.NamedTemporaryFile(mode="wb", suffix=".toml", delete=False) as f: - f.write(config_content) - f.flush() - - try: - # Test using the actual config module (uses tomllib on Python 3.11+) - from commit_check.config import toml_load - - with open(f.name, "rb") as config_file: - result = toml_load(config_file) - - assert result == {"test": {"value": "test"}} - - finally: - os.unlink(f.name) diff --git a/tests/config_test.py b/tests/config_test.py index fb2d5259..d900404d 100644 --- a/tests/config_test.py +++ b/tests/config_test.py @@ -3,6 +3,7 @@ import pytest import tempfile import os +import sys from pathlib import Path from unittest.mock import patch from commit_check.config import load_config, DEFAULT_CONFIG_PATHS @@ -204,3 +205,230 @@ def load(f): sys.modules["tomllib"] = original_tomllib if original_config is not None: sys.modules["commit_check.config"] = original_config + + +class TestConfigEdgeCases: + """Test TOML parsing errors and exception handling.""" + + @pytest.mark.benchmark + def test_load_config_invalid_toml(self): + """Test handling of invalid TOML syntax.""" + invalid_toml = b""" +[incomplete +missing closing bracket +""" + with tempfile.NamedTemporaryFile(mode="wb", suffix=".toml", delete=False) as f: + f.write(invalid_toml) + f.flush() + + try: + with pytest.raises(Exception): # Should raise a TOML parsing error + load_config(f.name) + finally: + os.unlink(f.name) + + @pytest.mark.benchmark + def test_load_config_file_permission_error(self): + """Test handling of file permission errors.""" + config_content = b""" +[checks] +test = true +""" + with tempfile.NamedTemporaryFile(mode="wb", suffix=".toml", delete=False) as f: + f.write(config_content) + f.flush() + + try: + # Remove read permissions to simulate permission error + os.chmod(f.name, 0o000) + + with pytest.raises(PermissionError): + load_config(f.name) + finally: + # Restore permissions and clean up + os.chmod(f.name, 0o644) + os.unlink(f.name) + + @pytest.mark.benchmark + def test_tomli_import_fallback(self): + """Test the tomli import fallback when tomllib is not available.""" + # We need to test the import fallback behavior + # This is complex because the imports happen at module load time + + # Create a minimal test by importing the config module's behavior + config_content = b""" +[test] +fallback = true +""" + + with tempfile.NamedTemporaryFile(mode="wb", suffix=".toml", delete=False) as f: + f.write(config_content) + f.flush() + + try: + # Test that we can load config regardless of which TOML library is used + config = load_config(f.name) + assert config["test"]["fallback"] is True + + # Since we can't easily test the import fallback on Python 3.11+, + # let's at least verify that the toml_load function works + from commit_check.config import toml_load + + with open(f.name, "rb") as config_file: + result = toml_load(config_file) + assert result["test"]["fallback"] is True + + finally: + os.unlink(f.name) + + +class TestConfigFallback: + """Direct test of the config import fallback using module manipulation.""" + + @pytest.mark.benchmark + def test_config_tomli_fallback_direct(self): + """Test config.py fallback to tomli by manipulating imports.""" + + # Save original state + original_modules = sys.modules.copy() + + try: + # Remove config module if already imported + if "commit_check.config" in sys.modules: + del sys.modules["commit_check.config"] + + # Make tomllib unavailable by raising ImportError + original_import = __import__ + + def mock_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "tomllib": + raise ImportError("No module named 'tomllib'") + # For tomli, return a working mock + if name == "tomli": + + class MockTomli: + @staticmethod + def load(f): + content = f.read().decode("utf-8") + # Simple parser for test + if 'test_key = "test_value"' in content: + return {"test_key": "test_value"} + return {} + + return MockTomli() + return original_import(name, globals, locals, fromlist, level) + + with patch("builtins.__import__", side_effect=mock_import): + # Now import config - should use tomli fallback + from commit_check.config import toml_load + + # Test that it works + config_content = b'test_key = "test_value"' + with tempfile.NamedTemporaryFile( + mode="wb", suffix=".toml", delete=False + ) as f: + f.write(config_content) + f.flush() + + try: + with open(f.name, "rb") as config_file: + result = toml_load(config_file) + assert result == {"test_key": "test_value"} + finally: + os.unlink(f.name) + + finally: + # Restore original modules + sys.modules.clear() + sys.modules.update(original_modules) + + +class TestConfigImport: + """Test import fallback by creating a test version of config.py.""" + + @pytest.mark.benchmark + @pytest.mark.skipif( + sys.version_info < (3, 11), reason="tomllib only available in Python 3.11+" + ) + def test_tomli_import_fallback_simulation(self): + """Test tomli import fallback by simulating the ImportError condition.""" + + # Create test code that simulates the config.py import logic + test_code = """ +try: + import tomllib + toml_load = tomllib.load + used_tomllib = True +except ImportError: + import tomli + toml_load = tomli.load + used_tomllib = False +""" + + # Test case 1: Normal case (tomllib available) + namespace1 = {} + exec(test_code, namespace1) + assert namespace1["used_tomllib"] is True + assert callable(namespace1["toml_load"]) + + # Test case 2: Simulate ImportError for tomllib + with patch.dict("sys.modules", {"tomllib": None}): + with patch( + "builtins.__import__", + side_effect=self._mock_import_error, + ): + namespace2 = {} + exec(test_code, namespace2) + assert namespace2["used_tomllib"] is False + assert callable(namespace2["toml_load"]) + + @staticmethod + def _mock_import_error(name, *args, **kwargs): + """Mock import function that raises ImportError for tomllib.""" + if name == "tomllib": + raise ImportError("No module named 'tomllib'") + + # For tomli, we need to mock it since it might not be installed + if name == "tomli": + # Create a mock tomli module + class MockTomli: + @staticmethod + def load(f): + # Simple TOML parser for testing + content = f.read().decode("utf-8") + if "[test]" in content and 'value = "test"' in content: + return {"test": {"value": "test"}} + return {} + + return MockTomli() + + # For all other imports, use the real import + return __import__(name, *args, **kwargs) + + @pytest.mark.benchmark + def test_import_paths_coverage(self): + """Ensure both import paths are conceptually tested.""" + # This test verifies that both the tomllib and tomli code paths + # would work in their respective environments + + # Test the function signature matches expectation + config_content = b""" +[test] +value = "test" +""" + + with tempfile.NamedTemporaryFile(mode="wb", suffix=".toml", delete=False) as f: + f.write(config_content) + f.flush() + + try: + # Test using the actual config module (uses tomllib on Python 3.11+) + from commit_check.config import toml_load + + with open(f.name, "rb") as config_file: + result = toml_load(config_file) + + assert result == {"test": {"value": "test"}} + + finally: + os.unlink(f.name) diff --git a/tests/engine_comprehensive_test.py b/tests/engine_comprehensive_test.py deleted file mode 100644 index b01a8951..00000000 --- a/tests/engine_comprehensive_test.py +++ /dev/null @@ -1,322 +0,0 @@ -"""Comprehensive tests for commit_check.engine module.""" - -from unittest.mock import patch -from commit_check.engine import ( - ValidationResult, - ValidationContext, - ValidationEngine, - CommitMessageValidator, - SubjectCapitalizationValidator, - SubjectImperativeValidator, - SubjectLengthValidator, - AuthorValidator, - BranchValidator, - MergeBaseValidator, - SignoffValidator, - BodyValidator, - CommitTypeValidator, -) -from commit_check.rule_builder import ValidationRule -import pytest - - -class TestValidationResult: - @pytest.mark.benchmark - def test_validation_result_values(self): - """Test ValidationResult enum values.""" - assert ValidationResult.PASS == 0 - assert ValidationResult.FAIL == 1 - - -class TestValidationContext: - @pytest.mark.benchmark - def test_validation_context_creation(self): - """Test ValidationContext creation.""" - context = ValidationContext() - assert context.stdin_text is None - assert context.commit_file is None - - context_with_data = ValidationContext( - stdin_text="test commit", commit_file="commit.txt" - ) - assert context_with_data.stdin_text == "test commit" - assert context_with_data.commit_file == "commit.txt" - - -class TestCommitMessageValidator: - @pytest.mark.benchmark - def test_commit_message_validator_creation(self): - """Test CommitMessageValidator creation.""" - rule = ValidationRule( - check="message", - regex="^(feat|fix):", - error="Invalid commit message", - suggest="Use conventional format", - ) - validator = CommitMessageValidator(rule) - assert validator.rule == rule - - @patch("commit_check.engine.has_commits") - @pytest.mark.benchmark - def test_commit_message_validator_with_stdin(self, mock_has_commits): - """Test CommitMessageValidator with stdin text.""" - mock_has_commits.return_value = True - - rule = ValidationRule( - check="message", - regex="^(feat|fix):", - error="Invalid commit message", - suggest="Use conventional format", - ) - validator = CommitMessageValidator(rule) - context = ValidationContext(stdin_text="feat: add new feature") - - result = validator.validate(context) - assert result == ValidationResult.PASS - - @patch("commit_check.engine.get_commit_info") - @patch("commit_check.engine.has_commits") - @pytest.mark.benchmark - def test_commit_message_validator_failure( - self, mock_has_commits, mock_get_commit_info - ): - """Test CommitMessageValidator failure case.""" - mock_has_commits.return_value = True - - rule = ValidationRule( - check="message", - regex="^(feat|fix):", - error="Invalid commit message", - suggest="Use conventional format", - ) - validator = CommitMessageValidator(rule) - context = ValidationContext(stdin_text="bad commit message") - - with patch.object(validator, "_print_failure") as mock_print: - result = validator.validate(context) - assert result == ValidationResult.FAIL - mock_print.assert_called_once() - - @patch("commit_check.engine.has_commits") - @pytest.mark.benchmark - def test_commit_message_validator_skip_validation(self, mock_has_commits): - """Test CommitMessageValidator skips when no commits and no stdin.""" - mock_has_commits.return_value = False - - rule = ValidationRule( - check="message", - regex="^(feat|fix):", - error="Invalid commit message", - suggest="Use conventional format", - ) - validator = CommitMessageValidator(rule) - context = ValidationContext() # No stdin_text - - result = validator.validate(context) - assert result == ValidationResult.PASS - - -class TestSubjectCapitalizationValidator: - @pytest.mark.benchmark - def test_subject_capitalization_pass(self): - """Test SubjectCapitalizationValidator pass case.""" - rule = ValidationRule( - check="subject_capitalized", - regex="^[A-Z]", - error="Subject must be capitalized", - suggest="Capitalize first letter", - ) - validator = SubjectCapitalizationValidator(rule) - # Use conventional commit format with capitalized description - context = ValidationContext(stdin_text="feat: Add new feature") - - result = validator.validate(context) - assert result == ValidationResult.PASS - - @pytest.mark.benchmark - def test_subject_capitalization_fail(self): - """Test SubjectCapitalizationValidator fail case.""" - rule = ValidationRule( - check="subject_capitalized", - regex="^[A-Z]", - error="Subject must be capitalized", - suggest="Capitalize first letter", - ) - validator = SubjectCapitalizationValidator(rule) - # Use conventional commit format with lowercase description - context = ValidationContext(stdin_text="feat: add new feature") - - with patch.object(validator, "_print_failure") as mock_print: - result = validator.validate(context) - assert result == ValidationResult.FAIL - mock_print.assert_called_once() - - -class TestSubjectImperativeValidator: - @pytest.mark.benchmark - def test_subject_imperative_pass(self): - """Test SubjectImperativeValidator pass case.""" - rule = ValidationRule( - check="subject_imperative", - regex="", - error="Subject must be imperative", - suggest="Use imperative mood", - ) - validator = SubjectImperativeValidator(rule) - # Use conventional commit with imperative verb "add" - context = ValidationContext(stdin_text="feat: add new feature") - - result = validator.validate(context) - assert result == ValidationResult.PASS - - @pytest.mark.benchmark - def test_subject_imperative_fail(self): - """Test SubjectImperativeValidator fail case.""" - rule = ValidationRule( - check="subject_imperative", - regex="", - error="Subject must be imperative", - suggest="Use imperative mood", - ) - validator = SubjectImperativeValidator(rule) - # Use past tense "added" which is not imperative - context = ValidationContext(stdin_text="feat: added new feature") - - with patch.object(validator, "_print_failure") as mock_print: - result = validator.validate(context) - assert result == ValidationResult.FAIL - mock_print.assert_called_once() - - -class TestSubjectLengthValidator: - @pytest.mark.benchmark - def test_subject_length_pass(self): - """Test SubjectLengthValidator pass case.""" - rule = ValidationRule( - check="subject_max_length", - regex="", - error="Subject too long", - suggest="Keep subject short", - value=50, - ) - validator = SubjectLengthValidator(rule) - context = ValidationContext(stdin_text="Add feature") - - result = validator.validate(context) - assert result == ValidationResult.PASS - - @pytest.mark.benchmark - def test_subject_length_fail(self): - """Test SubjectLengthValidator fail case.""" - rule = ValidationRule( - check="subject_max_length", - regex="", - error="Subject too long: max 10 characters", - suggest="Keep subject short", - value=10, - ) - validator = SubjectLengthValidator(rule) - context = ValidationContext(stdin_text="This is a very long subject line") - - with patch.object(validator, "_print_failure") as mock_print: - result = validator.validate(context) - assert result == ValidationResult.FAIL - mock_print.assert_called_once() - - -class TestValidationEngine: - @pytest.mark.benchmark - def test_validation_engine_creation(self): - """Test ValidationEngine creation.""" - rules = [ - ValidationRule( - check="message", - regex="^(feat|fix):", - error="Invalid commit message", - suggest="Use conventional format", - ) - ] - engine = ValidationEngine(rules) - assert engine.rules == rules - - @pytest.mark.benchmark - def test_validation_engine_validator_map(self): - """Test ValidationEngine VALIDATOR_MAP contains expected mappings.""" - engine = ValidationEngine([]) - - expected_mappings = { - "message": CommitMessageValidator, - "subject_capitalized": SubjectCapitalizationValidator, - "subject_imperative": SubjectImperativeValidator, - "subject_max_length": SubjectLengthValidator, - "subject_min_length": SubjectLengthValidator, - "author_name": AuthorValidator, - "author_email": AuthorValidator, - "branch": BranchValidator, - "merge_base": MergeBaseValidator, - "require_signed_off_by": SignoffValidator, - "require_body": BodyValidator, - "allow_merge_commits": CommitTypeValidator, - "allow_revert_commits": CommitTypeValidator, - "allow_empty_commits": CommitTypeValidator, - "allow_fixup_commits": CommitTypeValidator, - "allow_wip_commits": CommitTypeValidator, - "ignore_authors": CommitTypeValidator, - } - - for check, validator_class in expected_mappings.items(): - assert engine.VALIDATOR_MAP[check] == validator_class - - @pytest.mark.benchmark - def test_validation_engine_validate_all_pass(self): - """Test ValidationEngine validate_all with all passing rules.""" - rules = [ - ValidationRule( - check="message", - regex="^(feat|fix):", - error="Invalid commit message", - suggest="Use conventional format", - ) - ] - engine = ValidationEngine(rules) - context = ValidationContext(stdin_text="feat: add new feature") - - with patch("commit_check.engine.has_commits", return_value=True): - result = engine.validate_all(context) - assert result == ValidationResult.PASS - - @pytest.mark.benchmark - def test_validation_engine_validate_all_fail(self): - """Test ValidationEngine validate_all with failing rule.""" - rules = [ - ValidationRule( - check="message", - regex="^(feat|fix):", - error="Invalid commit message", - suggest="Use conventional format", - ) - ] - engine = ValidationEngine(rules) - context = ValidationContext(stdin_text="bad commit message") - - with patch("commit_check.engine.has_commits", return_value=True): - result = engine.validate_all(context) - assert result == ValidationResult.FAIL - - @pytest.mark.benchmark - def test_validation_engine_unknown_validator(self): - """Test ValidationEngine with unknown validator type.""" - rules = [ - ValidationRule( - check="unknown_check", - regex="", - error="Unknown error", - suggest="Unknown suggest", - ) - ] - engine = ValidationEngine(rules) - context = ValidationContext(stdin_text="test") - - # Should skip unknown validators and continue - result = engine.validate_all(context) - assert result == ValidationResult.PASS diff --git a/tests/engine_test.py b/tests/engine_test.py index 416b5785..76e69ce8 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -810,6 +810,34 @@ def test_validate_all_mixed_results(self): result = engine.validate_all(context) assert result == ValidationResult.FAIL # Any failure = overall failure + @pytest.mark.benchmark + def test_validation_engine_validator_map(self): + """Test ValidationEngine VALIDATOR_MAP contains expected mappings.""" + engine = ValidationEngine([]) + + expected_mappings = { + "message": CommitMessageValidator, + "subject_capitalized": SubjectCapitalizationValidator, + "subject_imperative": SubjectImperativeValidator, + "subject_max_length": SubjectLengthValidator, + "subject_min_length": SubjectLengthValidator, + "author_name": AuthorValidator, + "author_email": AuthorValidator, + "branch": BranchValidator, + "merge_base": MergeBaseValidator, + "require_signed_off_by": SignoffValidator, + "require_body": BodyValidator, + "allow_merge_commits": CommitTypeValidator, + "allow_revert_commits": CommitTypeValidator, + "allow_empty_commits": CommitTypeValidator, + "allow_fixup_commits": CommitTypeValidator, + "allow_wip_commits": CommitTypeValidator, + "ignore_authors": CommitTypeValidator, + } + + for check, validator_class in expected_mappings.items(): + assert engine.VALIDATOR_MAP[check] == validator_class + class TestSubjectValidator: """Test SubjectValidator base class.""" diff --git a/tests/util_test.py b/tests/util_test.py index 4e34dfc3..48138903 100644 --- a/tests/util_test.py +++ b/tests/util_test.py @@ -1,15 +1,24 @@ import pytest import subprocess -from commit_check.util import get_branch_name -from commit_check.util import has_commits -from commit_check.util import git_merge_base -from commit_check.util import get_commit_info -from commit_check.util import cmd_output -from commit_check.util import print_error_header -from commit_check.util import print_error_message -from commit_check.util import print_suggestion +import tempfile +import os +from pathlib import Path, PurePath +from commit_check.util import ( + get_branch_name, + has_commits, + git_merge_base, + get_commit_info, + cmd_output, + print_error_header, + print_error_message, + print_suggestion, + _find_check, + _load_toml, + _find_config_file, + validate_config, +) from subprocess import CalledProcessError, PIPE -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch class TestUtil: @@ -339,3 +348,197 @@ def test_print_suggestion_exit1(self, capfd): assert e.value.code == 1 stdout, _ = capfd.readouterr() assert "commit-check does not support" in stdout + + class TestHelperFunctions: + """Tests for utility helper functions to improve coverage.""" + + def test_find_check_found(self): + """Test _find_check when check is found.""" + checks = [ + {"check": "commit-message", "regex": ".*"}, + {"check": "branch-name", "regex": ".*"}, + ] + result = _find_check(checks, "commit-message") + assert result == {"check": "commit-message", "regex": ".*"} + + def test_find_check_not_found(self): + """Test _find_check when check is not found.""" + checks = [ + {"check": "commit-message", "regex": ".*"}, + ] + result = _find_check(checks, "author-name") + assert result is None + + def test_find_check_empty_list(self): + """Test _find_check with empty list.""" + checks = [] + result = _find_check(checks, "commit-message") + assert result is None + + def test_load_toml_file_not_found(self): + """Test _load_toml with non-existent file.""" + result = _load_toml(PurePath("/nonexistent/path/config.toml")) + assert result == {} + + def test_load_toml_invalid_toml(self): + """Test _load_toml with invalid TOML content.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".toml", delete=False + ) as f: + f.write("invalid toml { content") + temp_path = f.name + + try: + result = _load_toml(PurePath(temp_path)) + assert result == {} + finally: + os.unlink(temp_path) + + def test_load_toml_valid(self): + """Test _load_toml with valid TOML content.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".toml", delete=False + ) as f: + f.write('[checks]\ncommit_message = { pattern = ".*" }\n') + temp_path = f.name + + try: + result = _load_toml(PurePath(temp_path)) + assert isinstance(result, dict) + assert "checks" in result + finally: + os.unlink(temp_path) + + def test_find_config_file_directory_commit_check_toml(self): + """Test _find_config_file finds commit-check.toml in directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_file = Path(tmpdir) / "commit-check.toml" + config_file.write_text("[checks]") + + result = _find_config_file(tmpdir) + assert result == config_file + + def test_find_config_file_directory_cchk_toml(self): + """Test _find_config_file finds cchk.toml in directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_file = Path(tmpdir) / "cchk.toml" + config_file.write_text("[checks]") + + result = _find_config_file(tmpdir) + assert result == config_file + + def test_find_config_file_directory_priority(self): + """Test _find_config_file prefers commit-check.toml over cchk.toml.""" + with tempfile.TemporaryDirectory() as tmpdir: + config1 = Path(tmpdir) / "commit-check.toml" + config2 = Path(tmpdir) / "cchk.toml" + config1.write_text("[checks]") + config2.write_text("[checks]") + + result = _find_config_file(tmpdir) + assert result == config1 + + def test_find_config_file_directory_no_config(self): + """Test _find_config_file returns None when no config found in directory.""" + with tempfile.TemporaryDirectory() as tmpdir: + result = _find_config_file(tmpdir) + assert result is None + + def test_find_config_file_explicit_toml_exists(self): + """Test _find_config_file with explicit .toml file path.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".toml", delete=False + ) as f: + f.write("[checks]") + temp_path = f.name + + try: + result = _find_config_file(temp_path) + assert result == Path(temp_path) + finally: + os.unlink(temp_path) + + def test_find_config_file_explicit_toml_not_exists(self): + """Test _find_config_file with non-existent .toml file path.""" + result = _find_config_file("/nonexistent/config.toml") + assert result is None + + def test_find_config_file_non_toml_file(self): + """Test _find_config_file with non-.toml file.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".yml", delete=False + ) as f: + f.write("checks:") + temp_path = f.name + + try: + result = _find_config_file(temp_path) + assert result is None + finally: + os.unlink(temp_path) + + def test_validate_config_with_toml(self): + """Test validate_config loads and validates TOML config.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_file = Path(tmpdir) / "commit-check.toml" + config_file.write_text(""" +[checks.commit_message] +pattern = "^(feat|fix|docs|style|refactor|test|chore).*" +""") + + result = validate_config(tmpdir) + assert "checks" in result + assert isinstance(result["checks"], list) + + def test_validate_config_yaml_fallback(self): + """Test validate_config falls back to YAML when TOML not found.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".yml", delete=False + ) as f: + f.write(""" +checks: + - check: commit-message + regex: ".*" +""") + temp_path = f.name + + try: + result = validate_config(temp_path) + assert isinstance(result, dict) + finally: + os.unlink(temp_path) + + def test_validate_config_yaml_not_found(self): + """Test validate_config returns empty dict when YAML not found.""" + result = validate_config("/nonexistent/config.yml") + assert result == {} + + def test_validate_config_yaml_invalid(self): + """Test validate_config handles invalid YAML.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".yml", delete=False + ) as f: + f.write("invalid: yaml: : content:") + temp_path = f.name + + try: + result = validate_config(temp_path) + # Should return empty dict on error + assert isinstance(result, dict) + finally: + os.unlink(temp_path) + + def test_validate_config_empty_toml(self): + """Test validate_config with empty TOML file.""" + with tempfile.TemporaryDirectory() as tmpdir: + config_file = Path(tmpdir) / "commit-check.toml" + config_file.write_text("") + + result = validate_config(tmpdir) + assert result == {} + + @patch("commit_check.util._toml", None) + def test_load_toml_when_toml_not_available(self): + """Test _load_toml returns empty dict when toml library not available.""" + result = _load_toml(PurePath("/some/path/config.toml")) + assert result == {}