"""Test PostToolUse hook functionality.""" import tempfile from unittest.mock import patch from code_quality_guard import QualityConfig, posttooluse_hook class TestPostToolUseHook: """Test PostToolUse hook behavior.""" def test_non_write_tool_allowed(self): """Test that non-write/edit tools are always allowed.""" config = QualityConfig() hook_data = { "tool_name": "Read", "tool_output": {"status": "success"}, } result = posttooluse_hook(hook_data, config) assert result["hookSpecificOutput"]["hookEventName"] == "PostToolUse" assert "decision" not in result def test_file_path_extraction_dict(self): """Test file path extraction from dict output.""" config = QualityConfig() test_file = f"{tempfile.gettempdir()}/test.py" # Test with file_path key hook_data = { "tool_name": "Write", "tool_output": {"file_path": test_file}, } with patch("pathlib.Path.exists", return_value=True): with patch("pathlib.Path.read_text", return_value="def test(): pass"): result = posttooluse_hook(hook_data, config) assert "decision" not in result # Test with path key hook_data["tool_output"] = {"path": test_file} with patch("pathlib.Path.exists", return_value=True): with patch("pathlib.Path.read_text", return_value="def test(): pass"): result = posttooluse_hook(hook_data, config) assert "decision" not in result def test_file_path_extraction_string(self): """Test file path extraction from string output.""" config = QualityConfig() hook_data = { "tool_name": "Write", "tool_output": "File written successfully: /tmp/test.py", } with patch("pathlib.Path.exists", return_value=True): with patch("pathlib.Path.read_text", return_value="def test(): pass"): result = posttooluse_hook(hook_data, config) assert "decision" not in result def test_non_python_file_skipped(self): """Test that non-Python files are skipped.""" config = QualityConfig() hook_data = { "tool_name": "Write", "tool_output": {"file_path": f"{tempfile.gettempdir()}/test.js"}, } result = posttooluse_hook(hook_data, config) assert "decision" not in result def test_nonexistent_file_skipped(self): """Test that nonexistent files are skipped.""" config = QualityConfig() hook_data = { "tool_name": "Write", "tool_output": {"file_path": f"{tempfile.gettempdir()}/nonexistent.py"}, } with patch("pathlib.Path.exists", return_value=False): result = posttooluse_hook(hook_data, config) assert "decision" not in result def test_state_tracking_degradation(self): """Test state tracking detects quality degradation.""" config = QualityConfig(state_tracking_enabled=True) hook_data = { "tool_name": "Write", "tool_output": {"file_path": f"{tempfile.gettempdir()}/test.py"}, } with patch("pathlib.Path.exists", return_value=True): with patch("pathlib.Path.read_text", return_value="def test(): pass"): with patch("code_quality_guard.check_state_changes") as mock_check: mock_check.return_value = [ "⚠️ Reduced functions: 5 → 2", "⚠️ File size increased significantly: 100 → 250 lines", ] result = posttooluse_hook(hook_data, config) assert result["decision"] == "block" reason_text = result["reason"].lower() assert "post-write quality notes" in reason_text assert "reduced functions" in reason_text def test_cross_file_duplicates(self): """Test cross-file duplicate detection.""" config = QualityConfig(cross_file_check_enabled=True) hook_data = { "tool_name": "Write", "tool_output": {"file_path": f"{tempfile.gettempdir()}/test.py"}, } with patch("pathlib.Path.exists", return_value=True): with patch("pathlib.Path.read_text", return_value="def test(): pass"): with patch( "code_quality_guard.check_cross_file_duplicates", ) as mock_check: mock_check.return_value = ["⚠️ Cross-file duplication detected"] result = posttooluse_hook(hook_data, config) assert result["decision"] == "block" assert "cross-file duplication" in result["reason"].lower() def test_naming_convention_violations(self, non_pep8_code): """Test naming convention verification.""" config = QualityConfig(verify_naming=True) hook_data = { "tool_name": "Write", "tool_output": {"file_path": f"{tempfile.gettempdir()}/test.py"}, } with patch("pathlib.Path.exists", return_value=True): with patch("pathlib.Path.read_text", return_value=non_pep8_code): result = posttooluse_hook(hook_data, config) assert result["decision"] == "block" assert "non-pep8 function names" in result["reason"].lower() assert "non-pep8 class names" in result["reason"].lower() def test_show_success_message(self, clean_code): """Test success message when enabled.""" config = QualityConfig(show_success=True, verify_naming=False) hook_data = { "tool_name": "Write", "tool_output": {"file_path": f"{tempfile.gettempdir()}/test.py"}, } with patch("pathlib.Path.exists", return_value=True): with patch("pathlib.Path.read_text", return_value=clean_code): result = posttooluse_hook(hook_data, config) assert result["decision"] == "approve" assert ( "passed post-write verification" in result["systemMessage"].lower() ) def test_no_message_when_success_disabled(self, clean_code): """Test no message when show_success is disabled.""" config = QualityConfig(show_success=False, verify_naming=False) hook_data = { "tool_name": "Write", "tool_output": {"file_path": f"{tempfile.gettempdir()}/test.py"}, } with patch("pathlib.Path.exists", return_value=True): with patch("pathlib.Path.read_text", return_value=clean_code): result = posttooluse_hook(hook_data, config) assert "decision" not in result assert "systemMessage" not in result def test_all_features_combined(self): """Test all PostToolUse features combined.""" config = QualityConfig( state_tracking_enabled=True, cross_file_check_enabled=True, verify_naming=True, show_success=False, ) hook_data = { "tool_name": "Write", "tool_output": {"file_path": f"{tempfile.gettempdir()}/test.py"}, } with patch("pathlib.Path.exists", return_value=True): with patch("pathlib.Path.read_text", return_value="def test(): pass"): with patch("code_quality_guard.check_state_changes") as mock_state: with patch( "code_quality_guard.check_cross_file_duplicates", ) as mock_cross: with patch( "code_quality_guard.verify_naming_conventions", ) as mock_naming: mock_state.return_value = ["⚠️ Issue 1"] mock_cross.return_value = ["⚠️ Issue 2"] mock_naming.return_value = ["⚠️ Issue 3"] result = posttooluse_hook(hook_data, config) assert result["decision"] == "block" reason_text = result["reason"].lower() assert "issue 1" in reason_text assert "issue 2" in reason_text assert "issue 3" in reason_text def test_edit_tool_output(self): """Test Edit tool output handling.""" config = QualityConfig(verify_naming=True) hook_data = { "tool_name": "Edit", "tool_output": { "file_path": f"{tempfile.gettempdir()}/test.py", "status": "success", }, } with patch("pathlib.Path.exists", return_value=True): with patch("pathlib.Path.read_text", return_value="def test(): pass"): result = posttooluse_hook(hook_data, config) assert "decision" not in result def test_multiedit_tool_output(self): """Test MultiEdit tool output handling.""" config = QualityConfig(verify_naming=True) hook_data = { "tool_name": "MultiEdit", "tool_output": { "file_path": f"{tempfile.gettempdir()}/test.py", "edits_applied": 3, }, } with patch("pathlib.Path.exists", return_value=True): with patch("pathlib.Path.read_text", return_value="def test(): pass"): result = posttooluse_hook(hook_data, config) assert "decision" not in result def test_features_disabled(self): """Test with all features disabled.""" config = QualityConfig( state_tracking_enabled=False, cross_file_check_enabled=False, verify_naming=False, show_success=False, ) hook_data = { "tool_name": "Write", "tool_output": {"file_path": f"{tempfile.gettempdir()}/test.py"}, } with patch("pathlib.Path.exists", return_value=True): with patch("pathlib.Path.read_text", return_value="def test(): pass"): # Should not call any check functions with patch("code_quality_guard.check_state_changes") as mock_state: with patch( "code_quality_guard.check_cross_file_duplicates", ) as mock_cross: with patch( "code_quality_guard.verify_naming_conventions", ) as mock_naming: result = posttooluse_hook(hook_data, config) # Verify no checks were called mock_state.assert_not_called() mock_cross.assert_not_called() mock_naming.assert_not_called() assert "decision" not in result assert "systemMessage" not in result