diff --git a/tests/test_generator.py b/tests/test_generator.py new file mode 100644 index 0000000..2750cbb --- /dev/null +++ b/tests/test_generator.py @@ -0,0 +1,240 @@ +"""Tests for commit message generator.""" + +from unittest.mock import patch + +import pytest + +from src.analyzer import ChangeSet, ChangeType, StagedChange +from src.generator import ( + GenerationError, + detect_commit_type, + detect_scope, + generate_commit_message, + generate_description, +) + + +class TestDetectCommitType: + """Tests for commit type detection.""" + + def test_detects_feat_for_src_files(self): + """Test detecting 'feat' for source files.""" + change_set = ChangeSet([ + StagedChange("src/main.py", ChangeType.ADDED), + StagedChange("src/cli.py", ChangeType.MODIFIED), + ]) + result = detect_commit_type(change_set) + assert result == "feat" + + def test_detects_fix_for_bug_fixes(self): + """Test detecting 'fix' for bug-related changes.""" + change_set = ChangeSet([ + StagedChange("bug_fix.py", ChangeType.MODIFIED), + StagedChange("fix_login.py", ChangeType.MODIFIED), + ]) + result = detect_commit_type(change_set) + assert result == "fix" + + def test_detects_docs_for_markdown(self): + """Test detecting 'docs' for markdown files.""" + change_set = ChangeSet([ + StagedChange("README.md", ChangeType.MODIFIED), + StagedChange("docs/guide.md", ChangeType.ADDED), + ]) + result = detect_commit_type(change_set) + assert result == "docs" + + def test_detects_test_for_test_files(self): + """Test detecting 'test' for test files.""" + change_set = ChangeSet([ + StagedChange("tests/test_main.py", ChangeType.ADDED), + StagedChange("test_main.spec.js", ChangeType.MODIFIED), + ]) + result = detect_commit_type(change_set) + assert result == "test" + + def test_detects_chore_for_config(self): + """Test detecting 'chore' for configuration files.""" + change_set = ChangeSet([ + StagedChange("package.json", ChangeType.MODIFIED), + StagedChange(".gitignore", ChangeType.ADDED), + ]) + result = detect_commit_type(change_set) + assert result == "chore" + + def test_falls_back_to_chore(self): + """Test falling back to 'chore' when no pattern matches.""" + change_set = ChangeSet([ + StagedChange("unknown/file.xyz", ChangeType.ADDED), + ]) + result = detect_commit_type(change_set) + assert result == "chore" + + def test_custom_type_rules(self): + """Test using custom type rules.""" + change_set = ChangeSet([ + StagedChange("custom/feature.py", ChangeType.ADDED), + ]) + custom_rules = {"custom_type": ["custom/"]} + result = detect_commit_type(change_set, custom_rules) + assert result == "custom_type" + + +class TestDetectScope: + """Tests for scope detection.""" + + def test_detects_single_scope(self): + """Test detecting single scope from directory.""" + change_set = ChangeSet([ + StagedChange("src/cli/main.py", ChangeType.ADDED), + StagedChange("src/cli/commands.py", ChangeType.MODIFIED), + ]) + result = detect_scope(change_set) + assert result == "src" + + def test_detects_multiple_scopes(self): + """Test detecting multiple scopes.""" + change_set = ChangeSet([ + StagedChange("src/a.py", ChangeType.ADDED), + StagedChange("lib/b.py", ChangeType.MODIFIED), + ]) + result = detect_scope(change_set) + assert "src" in result and "lib" in result + + def test_empty_for_no_changes(self): + """Test returning empty string for no changes.""" + change_set = ChangeSet([]) + result = detect_scope(change_set) + assert result == "" + + def test_root_file_no_scope(self): + """Test no scope for root-level files.""" + change_set = ChangeSet([ + StagedChange("main.py", ChangeType.ADDED), + ]) + result = detect_scope(change_set) + assert result == "" + + +class TestGenerateDescription: + """Tests for description generation.""" + + def test_describes_single_added(self): + """Test generating description for single added file.""" + change_set = ChangeSet([ + StagedChange("new_file.py", ChangeType.ADDED), + ]) + result = generate_description(change_set) + assert result == "add new_file.py" + + def test_describes_single_deleted(self): + """Test generating description for deleted file.""" + change_set = ChangeSet([ + StagedChange("old_file.py", ChangeType.DELETED), + ]) + result = generate_description(change_set) + assert result == "remove old_file.py" + + def test_describes_single_modified(self): + """Test generating description for modified file.""" + change_set = ChangeSet([ + StagedChange("existing.py", ChangeType.MODIFIED), + ]) + result = generate_description(change_set) + assert result == "update existing.py" + + def test_describes_multiple_added(self): + """Test generating description for multiple added files.""" + change_set = ChangeSet([ + StagedChange("a.py", ChangeType.ADDED), + StagedChange("b.py", ChangeType.ADDED), + ]) + result = generate_description(change_set) + assert result == "add 2 files" + + def test_describes_multiple_modified(self): + """Test generating description for multiple modified files.""" + change_set = ChangeSet([ + StagedChange("a.py", ChangeType.MODIFIED), + StagedChange("b.py", ChangeType.MODIFIED), + ]) + result = generate_description(change_set) + assert result == "update 2 files" + + def test_default_for_empty(self): + """Test default description for empty change set.""" + change_set = ChangeSet([]) + result = generate_description(change_set) + assert result == "update" + + +class TestGenerateCommitMessage: + """Tests for full message generation.""" + + @patch("src.generator.ChangeAnalyzer") + def test_generates_valid_message(self, mock_analyzer): + """Test generating a complete commit message.""" + change_set = ChangeSet([ + StagedChange("src/main.py", ChangeType.ADDED), + ]) + mock_analyzer.return_value.get_staged_changes.return_value = change_set + + result = generate_commit_message() + + assert "feat" in result + assert "src" in result + + def test_raises_error_on_no_changes(self): + """Test raising error when no staged changes.""" + with patch("src.generator.ChangeAnalyzer") as mock_analyzer: + mock_analyzer.return_value.get_staged_changes.return_value = ChangeSet([]) + + with pytest.raises(GenerationError, match="No staged changes"): + generate_commit_message() + + def test_raises_error_on_invalid_repo(self): + """Test raising error for invalid repository.""" + with patch("src.generator.ChangeAnalyzer") as mock_analyzer: + mock_analyzer.return_value.get_staged_changes.side_effect = ValueError( + "Not a git repository" + ) + + with pytest.raises(GenerationError, match="Not a git repository"): + generate_commit_message() + + @patch("src.generator.ChangeAnalyzer") + def test_respects_config_template(self, mock_analyzer): + """Test using custom template from config.""" + change_set = ChangeSet([ + StagedChange("src/test.py", ChangeType.ADDED), + ]) + custom_config = {"template": "CUSTOM: {description}"} + + mock_analyzer.return_value.get_staged_changes.return_value = change_set + + with patch("src.generator.load_config") as mock_load_config: + mock_load_config.return_value = custom_config + + result = generate_commit_message() + + assert result.startswith("CUSTOM:") + + @patch("src.generator.ChangeAnalyzer") + def test_max_files_limit(self, mock_analyzer): + """Test respecting max files configuration.""" + changes = [StagedChange(f"file{i}.py", ChangeType.ADDED) for i in range(10)] + change_set = ChangeSet(changes) + custom_config = { + "include_file_list": True, + "max_files": 3, + "template": "{files}" + } + + mock_analyzer.return_value.get_staged_changes.return_value = change_set + + with patch("src.generator.load_config") as mock_load_config: + mock_load_config.return_value = custom_config + + result = generate_commit_message() + file_count = result.count("file") + assert file_count <= 3