"""Tests for token_rules module.""" from __future__ import annotations import sys from pathlib import Path from unittest.mock import patch import pytest # Add timmy_automations to path for imports sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "timmy_automations")) from utils import token_rules as tr class TestTokenEvent: """Test TokenEvent dataclass.""" def test_delta_calculation_reward(self): """Delta is positive for rewards.""" event = tr.TokenEvent( name="test", description="Test event", reward=10, penalty=0, category="test", ) assert event.delta == 10 def test_delta_calculation_penalty(self): """Delta is negative for penalties.""" event = tr.TokenEvent( name="test", description="Test event", reward=0, penalty=-5, category="test", ) assert event.delta == -5 def test_delta_calculation_mixed(self): """Delta is net of reward and penalty.""" event = tr.TokenEvent( name="test", description="Test event", reward=10, penalty=-3, category="test", ) assert event.delta == 7 class TestTokenRulesLoading: """Test TokenRules configuration loading.""" def test_loads_from_yaml_file(self, tmp_path): """Load configuration from YAML file.""" yaml = pytest.importorskip("yaml") config_file = tmp_path / "token_rules.yaml" config_data = { "version": "1.0.0-test", "events": { "test_event": { "description": "A test event", "reward": 15, "category": "test", } }, "gating_thresholds": {"test_op": 50}, "daily_limits": {"test": {"max_earn": 100, "max_spend": 10}}, "audit": {"log_all_transactions": False}, } config_file.write_text(yaml.dump(config_data)) rules = tr.TokenRules(config_path=config_file) assert rules.get_config_version() == "1.0.0-test" assert rules.get_delta("test_event") == 15 assert rules.get_gate_threshold("test_op") == 50 def test_fallback_when_yaml_missing(self, tmp_path): """Use fallback defaults when YAML file doesn't exist.""" config_file = tmp_path / "nonexistent.yaml" rules = tr.TokenRules(config_path=config_file) assert rules.get_config_version() == "fallback" # Fallback should have some basic events assert rules.get_delta("pr_merged") == 10 assert rules.get_delta("test_fixed") == 8 assert rules.get_delta("automation_failure") == -2 def test_fallback_when_yaml_not_installed(self, tmp_path): """Use fallback when PyYAML is not installed.""" with patch.dict(sys.modules, {"yaml": None}): config_file = tmp_path / "token_rules.yaml" config_file.write_text("version: '1.0.0'") rules = tr.TokenRules(config_path=config_file) assert rules.get_config_version() == "fallback" class TestTokenRulesGetDelta: """Test get_delta method.""" def test_get_delta_existing_event(self, tmp_path): """Get delta for configured event.""" yaml = pytest.importorskip("yaml") config_file = tmp_path / "token_rules.yaml" config_data = { "version": "1.0.0", "events": { "pr_merged": {"description": "PR merged", "reward": 10, "category": "merge"}, "automation_failure": {"description": "Failure", "penalty": -2, "category": "ops"}, }, } config_file.write_text(yaml.dump(config_data)) rules = tr.TokenRules(config_path=config_file) assert rules.get_delta("pr_merged") == 10 assert rules.get_delta("automation_failure") == -2 def test_get_delta_unknown_event(self, tmp_path): """Return 0 for unknown events.""" config_file = tmp_path / "nonexistent.yaml" rules = tr.TokenRules(config_path=config_file) assert rules.get_delta("unknown_event") == 0 class TestTokenRulesGetEvent: """Test get_event method.""" def test_get_event_returns_full_config(self, tmp_path): """Get full event configuration.""" yaml = pytest.importorskip("yaml") config_file = tmp_path / "token_rules.yaml" config_data = { "version": "1.0.0", "events": { "pr_merged": { "description": "PR merged successfully", "reward": 10, "category": "merge", "gate_threshold": 0, } }, } config_file.write_text(yaml.dump(config_data)) rules = tr.TokenRules(config_path=config_file) event = rules.get_event("pr_merged") assert event is not None assert event.name == "pr_merged" assert event.description == "PR merged successfully" assert event.reward == 10 assert event.category == "merge" assert event.gate_threshold == 0 def test_get_event_unknown_returns_none(self, tmp_path): """Return None for unknown event.""" config_file = tmp_path / "nonexistent.yaml" rules = tr.TokenRules(config_path=config_file) assert rules.get_event("unknown") is None class TestTokenRulesListEvents: """Test list_events method.""" def test_list_all_events(self, tmp_path): """List all configured events.""" yaml = pytest.importorskip("yaml") config_file = tmp_path / "token_rules.yaml" config_data = { "version": "1.0.0", "events": { "event_a": {"description": "A", "reward": 5, "category": "cat1"}, "event_b": {"description": "B", "reward": 10, "category": "cat2"}, "event_c": {"description": "C", "reward": 15, "category": "cat1"}, }, } config_file.write_text(yaml.dump(config_data)) rules = tr.TokenRules(config_path=config_file) events = rules.list_events() assert len(events) == 3 event_names = {e.name for e in events} assert "event_a" in event_names assert "event_b" in event_names assert "event_c" in event_names def test_list_events_by_category(self, tmp_path): """Filter events by category.""" yaml = pytest.importorskip("yaml") config_file = tmp_path / "token_rules.yaml" config_data = { "version": "1.0.0", "events": { "event_a": {"description": "A", "reward": 5, "category": "cat1"}, "event_b": {"description": "B", "reward": 10, "category": "cat2"}, "event_c": {"description": "C", "reward": 15, "category": "cat1"}, }, } config_file.write_text(yaml.dump(config_data)) rules = tr.TokenRules(config_path=config_file) events = rules.list_events(category="cat1") assert len(events) == 2 for event in events: assert event.category == "cat1" class TestTokenRulesGating: """Test gating threshold methods.""" def test_check_gate_with_threshold(self, tmp_path): """Check gate when threshold is defined.""" yaml = pytest.importorskip("yaml") config_file = tmp_path / "token_rules.yaml" config_data = { "version": "1.0.0", "events": {}, "gating_thresholds": {"pr_merge": 50}, } config_file.write_text(yaml.dump(config_data)) rules = tr.TokenRules(config_path=config_file) assert rules.check_gate("pr_merge", current_tokens=100) is True assert rules.check_gate("pr_merge", current_tokens=50) is True assert rules.check_gate("pr_merge", current_tokens=49) is False assert rules.check_gate("pr_merge", current_tokens=0) is False def test_check_gate_no_threshold(self, tmp_path): """Check gate when no threshold is defined (always allowed).""" yaml = pytest.importorskip("yaml") config_file = tmp_path / "token_rules.yaml" config_data = { "version": "1.0.0", "events": {}, "gating_thresholds": {}, } config_file.write_text(yaml.dump(config_data)) rules = tr.TokenRules(config_path=config_file) # No threshold defined, should always be allowed assert rules.check_gate("unknown_op", current_tokens=0) is True assert rules.check_gate("unknown_op", current_tokens=-100) is True def test_get_gate_threshold(self, tmp_path): """Get threshold value.""" yaml = pytest.importorskip("yaml") config_file = tmp_path / "token_rules.yaml" config_data = { "version": "1.0.0", "gating_thresholds": {"pr_merge": 50, "sensitive_op": 100}, } config_file.write_text(yaml.dump(config_data)) rules = tr.TokenRules(config_path=config_file) assert rules.get_gate_threshold("pr_merge") == 50 assert rules.get_gate_threshold("sensitive_op") == 100 assert rules.get_gate_threshold("unknown") is None class TestTokenRulesDailyLimits: """Test daily limits methods.""" def test_get_daily_limits(self, tmp_path): """Get daily limits for a category.""" yaml = pytest.importorskip("yaml") config_file = tmp_path / "token_rules.yaml" config_data = { "version": "1.0.0", "daily_limits": { "triage": {"max_earn": 100, "max_spend": 0}, "merge": {"max_earn": 50, "max_spend": 10}, }, } config_file.write_text(yaml.dump(config_data)) rules = tr.TokenRules(config_path=config_file) triage_limits = rules.get_daily_limits("triage") assert triage_limits is not None assert triage_limits.max_earn == 100 assert triage_limits.max_spend == 0 merge_limits = rules.get_daily_limits("merge") assert merge_limits is not None assert merge_limits.max_earn == 50 assert merge_limits.max_spend == 10 def test_get_daily_limits_unknown(self, tmp_path): """Return None for unknown category.""" yaml = pytest.importorskip("yaml") config_file = tmp_path / "token_rules.yaml" config_data = {"version": "1.0.0", "daily_limits": {}} config_file.write_text(yaml.dump(config_data)) rules = tr.TokenRules(config_path=config_file) assert rules.get_daily_limits("unknown") is None class TestTokenRulesComputeTransaction: """Test compute_transaction method.""" def test_compute_successful_transaction(self, tmp_path): """Compute transaction for valid event.""" yaml = pytest.importorskip("yaml") config_file = tmp_path / "token_rules.yaml" config_data = { "version": "1.0.0", "events": { "pr_merged": {"description": "PR merged", "reward": 10, "category": "merge"} }, } config_file.write_text(yaml.dump(config_data)) rules = tr.TokenRules(config_path=config_file) result = rules.compute_transaction("pr_merged", current_tokens=100) assert result["event"] == "pr_merged" assert result["delta"] == 10 assert result["category"] == "merge" assert result["allowed"] is True assert result["new_balance"] == 110 assert result["limit_reached"] is False def test_compute_unknown_event(self, tmp_path): """Compute transaction for unknown event.""" config_file = tmp_path / "nonexistent.yaml" rules = tr.TokenRules(config_path=config_file) result = rules.compute_transaction("unknown_event", current_tokens=50) assert result["event"] == "unknown_event" assert result["delta"] == 0 assert result["allowed"] is False assert result["reason"] == "unknown_event" assert result["new_balance"] == 50 def test_compute_with_gate_check(self, tmp_path): """Compute transaction respects gating.""" yaml = pytest.importorskip("yaml") config_file = tmp_path / "token_rules.yaml" config_data = { "version": "1.0.0", "events": { "sensitive_op": { "description": "Sensitive", "reward": 50, "category": "sensitive", "gate_threshold": 100, } }, } config_file.write_text(yaml.dump(config_data)) rules = tr.TokenRules(config_path=config_file) # With enough tokens result = rules.compute_transaction("sensitive_op", current_tokens=150) assert result["allowed"] is True # Without enough tokens result = rules.compute_transaction("sensitive_op", current_tokens=50) assert result["allowed"] is False assert "gate_reason" in result def test_compute_with_daily_limits(self, tmp_path): """Compute transaction respects daily limits.""" yaml = pytest.importorskip("yaml") config_file = tmp_path / "token_rules.yaml" config_data = { "version": "1.0.0", "events": { "triage_action": { "description": "Triage", "reward": 20, "category": "triage", } }, "daily_limits": {"triage": {"max_earn": 50, "max_spend": 0}}, } config_file.write_text(yaml.dump(config_data)) rules = tr.TokenRules(config_path=config_file) # Within limit daily_earned = {"triage": 20} result = rules.compute_transaction( "triage_action", current_tokens=100, current_daily_earned=daily_earned ) assert result["allowed"] is True assert result["limit_reached"] is False # Would exceed limit (20 + 20 > 50 is false, so this should be fine) # Let's test with higher current earned daily_earned = {"triage": 40} result = rules.compute_transaction( "triage_action", current_tokens=100, current_daily_earned=daily_earned ) assert result["allowed"] is False assert result["limit_reached"] is True assert "limit_reason" in result class TestTokenRulesCategories: """Test category methods.""" def test_get_categories(self, tmp_path): """Get all unique categories.""" yaml = pytest.importorskip("yaml") config_file = tmp_path / "token_rules.yaml" config_data = { "version": "1.0.0", "events": { "event_a": {"description": "A", "reward": 5, "category": "cat1"}, "event_b": {"description": "B", "reward": 10, "category": "cat2"}, "event_c": {"description": "C", "reward": 15, "category": "cat1"}, }, } config_file.write_text(yaml.dump(config_data)) rules = tr.TokenRules(config_path=config_file) categories = rules.get_categories() assert sorted(categories) == ["cat1", "cat2"] class TestTokenRulesAudit: """Test audit methods.""" def test_is_auditable_true(self, tmp_path): """Check if auditable when enabled.""" yaml = pytest.importorskip("yaml") config_file = tmp_path / "token_rules.yaml" config_data = {"version": "1.0.0", "audit": {"log_all_transactions": True}} config_file.write_text(yaml.dump(config_data)) rules = tr.TokenRules(config_path=config_file) assert rules.is_auditable() is True def test_is_auditable_false(self, tmp_path): """Check if auditable when disabled.""" yaml = pytest.importorskip("yaml") config_file = tmp_path / "token_rules.yaml" config_data = {"version": "1.0.0", "audit": {"log_all_transactions": False}} config_file.write_text(yaml.dump(config_data)) rules = tr.TokenRules(config_path=config_file) assert rules.is_auditable() is False class TestConvenienceFunctions: """Test module-level convenience functions.""" def test_get_token_delta(self, tmp_path): """Convenience function returns delta.""" config_file = tmp_path / "nonexistent.yaml" with patch.object(tr.TokenRules, "CONFIG_PATH", config_file): delta = tr.get_token_delta("pr_merged") assert delta == 10 # From fallback def test_check_operation_gate(self, tmp_path): """Convenience function checks gate.""" config_file = tmp_path / "nonexistent.yaml" with patch.object(tr.TokenRules, "CONFIG_PATH", config_file): # Fallback has pr_merge gate at 0 assert tr.check_operation_gate("pr_merge", current_tokens=0) is True assert tr.check_operation_gate("pr_merge", current_tokens=100) is True def test_compute_token_reward(self, tmp_path): """Convenience function computes reward.""" config_file = tmp_path / "nonexistent.yaml" with patch.object(tr.TokenRules, "CONFIG_PATH", config_file): result = tr.compute_token_reward("pr_merged", current_tokens=50) assert result["event"] == "pr_merged" assert result["delta"] == 10 assert result["new_balance"] == 60 def test_list_token_events(self, tmp_path): """Convenience function lists events.""" config_file = tmp_path / "nonexistent.yaml" with patch.object(tr.TokenRules, "CONFIG_PATH", config_file): events = tr.list_token_events() assert len(events) >= 3 # Fallback has at least 3 events # Check structure for event in events: assert "name" in event assert "description" in event assert "delta" in event assert "category" in event