# -*- coding: utf-8 -*- """ Tests für das Scheduler-System. """ import asyncio import json import os import tempfile from datetime import datetime, time, timedelta from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from trixy_core.scheduler import ( # Job ScheduledJob, JobConfig, JobState, JobResult, # Triggers CronTrigger, IntervalTrigger, DateTimeTrigger, EventTrigger, AndTrigger, OrTrigger, TriggerState, # Conditions TimeRangeCondition, DayOfWeekCondition, DateRangeCondition, RecentActivityCondition, ActivityCountCondition, SensorThresholdCondition, ComparisonOperator, StateCondition, BooleanCondition, ExistsCondition, AndCondition, OrCondition, NotCondition, XorCondition, # Script & File Conditions ScriptCondition, AsyncScriptCondition, JsonValueCondition, IniValueCondition, YamlValueCondition, EnvFileValueCondition, # Expression ExpressionCondition, LambdaCondition, satellites_connected, plugin_enabled, # Actions Action, ActionResult, EmitEventAction, CallbackAction, CommandAction, # Scheduler Scheduler, SchedulerConfig, ) from trixy_core.scheduler.trigger.base import TriggerContext from trixy_core.scheduler.trigger.cron import CronField, CronExpression, CronPresets # ============================================================================= # Trigger Tests # ============================================================================= class TestCronField: """Tests für CronField.""" def test_wildcard(self): """Test Wildcard '*'.""" field = CronField("*", 0, 59) assert field.matches(0) assert field.matches(30) assert field.matches(59) def test_single_value(self): """Test einzelner Wert.""" field = CronField("15", 0, 59) assert not field.matches(0) assert field.matches(15) assert not field.matches(30) def test_range(self): """Test Range '1-5'.""" field = CronField("1-5", 0, 10) assert not field.matches(0) assert field.matches(1) assert field.matches(3) assert field.matches(5) assert not field.matches(6) def test_step(self): """Test Step '*/15'.""" field = CronField("*/15", 0, 59) assert field.matches(0) assert field.matches(15) assert field.matches(30) assert field.matches(45) assert not field.matches(10) def test_list(self): """Test Liste '1,5,10'.""" field = CronField("1,5,10", 0, 59) assert field.matches(1) assert field.matches(5) assert field.matches(10) assert not field.matches(2) def test_aliases(self): """Test Aliase für Wochentage.""" field = CronField("MON", 0, 6, {"MON": 1, "TUE": 2}) assert field.matches(1) assert not field.matches(0) class TestCronTrigger: """Tests für CronTrigger.""" def test_every_minute(self): """Test jede Minute.""" trigger = CronTrigger("* * * * *") ctx = TriggerContext(current_time=datetime(2024, 1, 15, 10, 30, 0)) assert trigger.should_fire(ctx) def test_specific_time(self): """Test spezifische Zeit.""" trigger = CronTrigger("30 10 * * *") # 10:30 # Match ctx1 = TriggerContext(current_time=datetime(2024, 1, 15, 10, 30, 0)) assert trigger.should_fire(ctx1) # No match ctx2 = TriggerContext(current_time=datetime(2024, 1, 15, 10, 31, 0)) assert not trigger.should_fire(ctx2) def test_weekdays(self): """Test Werktage.""" trigger = CronTrigger("0 9 * * 1-5") # 9:00, Mo-Fr # Montag ctx1 = TriggerContext(current_time=datetime(2024, 1, 15, 9, 0, 0)) # Montag assert trigger.should_fire(ctx1) # Samstag ctx2 = TriggerContext(current_time=datetime(2024, 1, 20, 9, 0, 0)) # Samstag assert not trigger.should_fire(ctx2) def test_get_next_fire_time(self): """Test nächste Auslösezeit.""" trigger = CronTrigger("0 * * * *") # Jede volle Stunde now = datetime(2024, 1, 15, 10, 30, 0) next_time = trigger.get_next_fire_time(now) assert next_time is not None assert next_time.minute == 0 assert next_time.hour == 11 def test_serialization(self): """Test Serialisierung.""" trigger = CronTrigger("*/5 * * * *", name="Test") data = trigger.to_dict() assert data["expression"] == "*/5 * * * *" assert data["name"] == "Test" restored = CronTrigger.from_dict(data) assert restored.expression == trigger.expression class TestIntervalTrigger: """Tests für IntervalTrigger.""" def test_basic_interval(self): """Test einfaches Intervall.""" trigger = IntervalTrigger(minutes=5) assert trigger.interval_seconds == 300 def test_should_fire_first_time(self): """Test erste Auslösung.""" trigger = IntervalTrigger(minutes=5) ctx = TriggerContext(current_time=datetime.now()) assert trigger.should_fire(ctx) def test_should_fire_after_interval(self): """Test Auslösung nach Intervall.""" trigger = IntervalTrigger(minutes=5) now = datetime.now() past = now - timedelta(minutes=6) ctx = TriggerContext(current_time=now, last_fire_time=past) assert trigger.should_fire(ctx) def test_should_not_fire_before_interval(self): """Test keine Auslösung vor Intervall.""" trigger = IntervalTrigger(minutes=5) now = datetime.now() recent = now - timedelta(minutes=2) ctx = TriggerContext(current_time=now, last_fire_time=recent) assert not trigger.should_fire(ctx) def test_end_time(self): """Test Endzeit.""" end = datetime.now() - timedelta(hours=1) trigger = IntervalTrigger(minutes=5, end_time=end) ctx = TriggerContext(current_time=datetime.now()) assert not trigger.should_fire(ctx) assert trigger.state == TriggerState.FINISHED class TestDateTimeTrigger: """Tests für DateTimeTrigger.""" def test_future_trigger(self): """Test zukünftiger Trigger.""" future = datetime.now() + timedelta(hours=1) trigger = DateTimeTrigger(future) ctx = TriggerContext(current_time=datetime.now()) assert not trigger.should_fire(ctx) def test_trigger_at_time(self): """Test Auslösung zum Zeitpunkt.""" now = datetime.now() trigger = DateTimeTrigger(now, tolerance_seconds=5) ctx = TriggerContext(current_time=now) assert trigger.should_fire(ctx) def test_trigger_finished_after_fire(self): """Test Trigger beendet nach Auslösung.""" now = datetime.now() trigger = DateTimeTrigger(now) trigger.fire() assert trigger.state == TriggerState.FINISHED assert trigger.get_next_fire_time() is None def test_helper_methods(self): """Test Helper-Methoden.""" trigger = DateTimeTrigger.in_minutes(30) assert trigger.run_at > datetime.now() trigger2 = DateTimeTrigger.at_time(14, 30) assert trigger2.run_at.hour == 14 assert trigger2.run_at.minute == 30 class TestEventTrigger: """Tests für EventTrigger.""" def test_single_event(self): """Test einzelnes Event.""" trigger = EventTrigger("test_event") assert "test_event" in trigger.event_names def test_multiple_events(self): """Test mehrere Events.""" trigger = EventTrigger(["event_a", "event_b"]) assert len(trigger.event_names) == 2 def test_should_fire_on_event(self): """Test Auslösung bei Event.""" trigger = EventTrigger("test_event") ctx = TriggerContext( current_time=datetime.now(), event_name="test_event", event_data={"key": "value"}, ) assert trigger.should_fire(ctx) def test_should_not_fire_on_other_event(self): """Test keine Auslösung bei anderem Event.""" trigger = EventTrigger("test_event") ctx = TriggerContext( current_time=datetime.now(), event_name="other_event", ) assert not trigger.should_fire(ctx) def test_filter_function(self): """Test Filter-Funktion.""" trigger = EventTrigger( "test_event", filter_fn=lambda d: d.get("type") == "important", ) # Matches ctx1 = TriggerContext( current_time=datetime.now(), event_name="test_event", event_data={"type": "important"}, ) assert trigger.should_fire(ctx1) # No match ctx2 = TriggerContext( current_time=datetime.now(), event_name="test_event", event_data={"type": "normal"}, ) assert not trigger.should_fire(ctx2) class TestCompositeTriggers: """Tests für Composite Triggers.""" def test_and_trigger(self): """Test AND Trigger.""" # Beide müssen feuern trigger1 = EventTrigger("event_a") trigger2 = EventTrigger("event_b") and_trigger = AndTrigger([trigger1, trigger2]) # Nur event_a ctx1 = TriggerContext( current_time=datetime.now(), event_name="event_a", ) assert not and_trigger.should_fire(ctx1) def test_or_trigger(self): """Test OR Trigger.""" trigger1 = CronTrigger("30 10 * * *") # 10:30 trigger2 = CronTrigger("30 14 * * *") # 14:30 or_trigger = OrTrigger([trigger1, trigger2]) # 10:30 match ctx = TriggerContext(current_time=datetime(2024, 1, 15, 10, 30, 0)) assert or_trigger.should_fire(ctx) # ============================================================================= # Condition Tests # ============================================================================= class TestTimeConditions: """Tests für Zeit-Conditions.""" def test_time_range_normal(self): """Test normales Zeitfenster.""" cond = TimeRangeCondition(time(9, 0), time(17, 0)) # In range ctx1 = {"current_time": datetime(2024, 1, 15, 12, 0, 0)} assert cond.evaluate(ctx1) # Out of range ctx2 = {"current_time": datetime(2024, 1, 15, 20, 0, 0)} assert not cond.evaluate(ctx2) def test_time_range_overnight(self): """Test Zeitfenster über Mitternacht.""" cond = TimeRangeCondition(time(22, 0), time(6, 0)) # 23:00 - in range ctx1 = {"current_time": datetime(2024, 1, 15, 23, 0, 0)} assert cond.evaluate(ctx1) # 03:00 - in range ctx2 = {"current_time": datetime(2024, 1, 15, 3, 0, 0)} assert cond.evaluate(ctx2) # 12:00 - out of range ctx3 = {"current_time": datetime(2024, 1, 15, 12, 0, 0)} assert not cond.evaluate(ctx3) def test_day_of_week(self): """Test Wochentag-Condition.""" cond = DayOfWeekCondition.weekdays() # Montag ctx1 = {"current_time": datetime(2024, 1, 15, 12, 0, 0)} # Montag assert cond.evaluate(ctx1) # Samstag ctx2 = {"current_time": datetime(2024, 1, 20, 12, 0, 0)} # Samstag assert not cond.evaluate(ctx2) class TestActivityConditions: """Tests für Aktivitäts-Conditions.""" def test_recent_activity(self): """Test kürzliche Aktivität.""" cond = RecentActivityCondition("last_voice_input", minutes=10) now = datetime.now() # Recent - 5 minutes ago ctx1 = { "current_time": now, "last_voice_input": now - timedelta(minutes=5), } assert cond.evaluate(ctx1) # Not recent - 15 minutes ago ctx2 = { "current_time": now, "last_voice_input": now - timedelta(minutes=15), } assert not cond.evaluate(ctx2) def test_activity_count(self): """Test Aktivitäts-Zähler.""" cond = ActivityCountCondition("error_count", min_count=5) assert cond.evaluate({"error_count": 10}) assert cond.evaluate({"error_count": 5}) assert not cond.evaluate({"error_count": 3}) class TestSensorConditions: """Tests für Sensor-Conditions.""" def test_threshold_greater(self): """Test Schwellenwert größer.""" cond = SensorThresholdCondition( "temperature", ComparisonOperator.GREATER, 25, ) assert cond.evaluate({"temperature": 30}) assert not cond.evaluate({"temperature": 20}) def test_threshold_between(self): """Test Schwellenwert zwischen.""" cond = SensorThresholdCondition( "volume", ComparisonOperator.BETWEEN, (0.5, 0.8), ) assert cond.evaluate({"volume": 0.6}) assert not cond.evaluate({"volume": 0.9}) def test_threshold_in_list(self): """Test Wert in Liste.""" cond = SensorThresholdCondition( "status", ComparisonOperator.IN, ["active", "standby"], ) assert cond.evaluate({"status": "active"}) assert not cond.evaluate({"status": "offline"}) class TestStateConditions: """Tests für Zustands-Conditions.""" def test_state_condition(self): """Test Zustands-Condition.""" cond = StateCondition("system_mode", "active") assert cond.evaluate({"system_mode": "active"}) assert cond.evaluate({"system_mode": "ACTIVE"}) # Case-insensitive assert not cond.evaluate({"system_mode": "standby"}) def test_boolean_condition(self): """Test Boolean-Condition.""" cond = BooleanCondition("is_home") assert cond.evaluate({"is_home": True}) assert not cond.evaluate({"is_home": False}) assert not cond.evaluate({}) def test_exists_condition(self): """Test Exists-Condition.""" cond = ExistsCondition("user_data") assert cond.evaluate({"user_data": {"name": "Test"}}) assert not cond.evaluate({}) assert not cond.evaluate({"user_data": None}) class TestCompositeConditions: """Tests für Composite Conditions.""" def test_and_condition(self): """Test AND Condition.""" cond = AndCondition([ BooleanCondition("flag_a"), BooleanCondition("flag_b"), ]) assert cond.evaluate({"flag_a": True, "flag_b": True}) assert not cond.evaluate({"flag_a": True, "flag_b": False}) def test_or_condition(self): """Test OR Condition.""" cond = OrCondition([ BooleanCondition("flag_a"), BooleanCondition("flag_b"), ]) assert cond.evaluate({"flag_a": True, "flag_b": False}) assert cond.evaluate({"flag_a": False, "flag_b": True}) assert not cond.evaluate({"flag_a": False, "flag_b": False}) def test_not_condition(self): """Test NOT Condition.""" cond = NotCondition(BooleanCondition("maintenance_mode")) assert cond.evaluate({"maintenance_mode": False}) assert not cond.evaluate({"maintenance_mode": True}) def test_xor_condition(self): """Test XOR Condition.""" cond = XorCondition([ BooleanCondition("flag_a"), BooleanCondition("flag_b"), ]) assert cond.evaluate({"flag_a": True, "flag_b": False}) assert cond.evaluate({"flag_a": False, "flag_b": True}) assert not cond.evaluate({"flag_a": True, "flag_b": True}) assert not cond.evaluate({"flag_a": False, "flag_b": False}) def test_inverted_condition(self): """Test invertierte Condition.""" cond = BooleanCondition("flag", invert=True) assert cond.evaluate({"flag": False}) assert not cond.evaluate({"flag": True}) # ============================================================================= # Action Tests # ============================================================================= class TestEmitEventAction: """Tests für EmitEventAction.""" @pytest.mark.asyncio async def test_basic_emit(self): """Test einfaches Event-Emit.""" action = EmitEventAction("test_event", {"key": "value"}) result = await action.execute({}) assert result.success assert result.result["event_name"] == "test_event" assert result.result["event_data"]["key"] == "value" @pytest.mark.asyncio async def test_emit_with_event_manager(self): """Test Emit mit EventManager.""" mock_em = AsyncMock() action = EmitEventAction("test_event", {"key": "value"}) await action.execute({"event_manager": mock_em}) mock_em.emit.assert_called_once() class TestCallbackAction: """Tests für CallbackAction.""" @pytest.mark.asyncio async def test_sync_callback(self): """Test synchroner Callback.""" def my_func(x, y): return x + y action = CallbackAction(my_func, args=(2, 3)) result = await action.execute({}) assert result.success assert result.result == 5 @pytest.mark.asyncio async def test_async_callback(self): """Test asynchroner Callback.""" async def my_async_func(x): await asyncio.sleep(0.01) return x * 2 action = CallbackAction(my_async_func, args=(5,)) result = await action.execute({}) assert result.success assert result.result == 10 @pytest.mark.asyncio async def test_callback_with_context(self): """Test Callback mit Kontext.""" def my_func(ctx): return ctx.get("value", 0) * 2 action = CallbackAction(my_func, pass_context=True) result = await action.execute({"value": 10}) assert result.success assert result.result == 20 class TestCommandAction: """Tests für CommandAction.""" @pytest.mark.asyncio async def test_simple_command(self): """Test einfacher Befehl.""" action = CommandAction("echo 'Hello World'", shell=True) result = await action.execute({}) assert result.success assert "Hello World" in result.result["stdout"] @pytest.mark.asyncio async def test_command_list(self): """Test Befehl als Liste.""" action = CommandAction(["echo", "Hello"]) result = await action.execute({}) assert result.success assert "Hello" in result.result["stdout"] @pytest.mark.asyncio async def test_command_failure(self): """Test fehlgeschlagener Befehl.""" action = CommandAction("exit 1", shell=True) result = await action.execute({}) assert not result.success assert result.error is not None # ============================================================================= # Job Tests # ============================================================================= class TestScheduledJob: """Tests für ScheduledJob.""" def test_create_job(self): """Test Job-Erstellung.""" trigger = IntervalTrigger(minutes=5) action = EmitEventAction("test_event") config = JobConfig(name="Test Job") job = ScheduledJob( trigger=trigger, actions=[action], config=config, ) assert job.name == "Test Job" assert job.is_enabled assert job.state == JobState.PENDING def test_job_conditions(self): """Test Job mit Conditions.""" job = ScheduledJob() cond = BooleanCondition("flag") job.add_condition(cond) assert len(job.conditions) == 1 assert job.should_run_now({"flag": True}) assert not job.should_run_now({"flag": False}) def test_job_enable_disable(self): """Test Job aktivieren/deaktivieren.""" job = ScheduledJob() job.disable() assert not job.is_enabled assert job.state == JobState.DISABLED job.enable() assert job.is_enabled def test_job_record_run(self): """Test Ausführungs-Aufzeichnung.""" job = ScheduledJob() result = JobResult( job_id=job.job_id, success=True, start_time=datetime.now(), end_time=datetime.now(), ) job.record_run(result) assert job.run_count == 1 assert job.failure_count == 0 assert job.last_result == result def test_job_max_failures(self): """Test maximale Fehler.""" config = JobConfig(max_failures=2) job = ScheduledJob(config=config) for i in range(2): result = JobResult( job_id=job.job_id, success=False, start_time=datetime.now(), end_time=datetime.now(), error="Test error", ) job.record_run(result) assert job.state == JobState.DISABLED def test_job_serialization(self): """Test Job-Serialisierung.""" trigger = CronTrigger("0 * * * *") action = EmitEventAction("test_event") config = JobConfig(name="Test", description="Test Job") job = ScheduledJob( job_id="test-job-1", trigger=trigger, actions=[action], config=config, ) data = job.to_dict() assert data["job_id"] == "test-job-1" assert data["name"] == "Test" assert data["trigger"]["expression"] == "0 * * * *" # ============================================================================= # Scheduler Tests # ============================================================================= class TestScheduler: """Tests für Scheduler.""" @pytest.fixture def temp_dir(self): """Temporäres Verzeichnis für Tests.""" with tempfile.TemporaryDirectory() as td: yield td @pytest.fixture def scheduler(self, temp_dir): """Scheduler-Instanz für Tests.""" config = SchedulerConfig( jobs_directory=temp_dir, auto_load=False, auto_save=True, ) return Scheduler(config=config) def test_create_scheduler(self, scheduler): """Test Scheduler-Erstellung.""" assert scheduler.job_count == 0 assert not scheduler.is_running def test_add_job(self, scheduler): """Test Job hinzufügen.""" trigger = IntervalTrigger(minutes=5) action = EmitEventAction("test_event") job = scheduler.create_job( trigger=trigger, actions=action, config=JobConfig(name="Test"), ) assert scheduler.job_count == 1 assert scheduler.get_job(job.job_id) is not None def test_remove_job(self, scheduler): """Test Job entfernen.""" job = scheduler.create_job( trigger=IntervalTrigger(minutes=5), actions=EmitEventAction("test"), ) job_id = job.job_id assert scheduler.remove_job(job_id) assert scheduler.get_job(job_id) is None def test_save_load_job(self, scheduler, temp_dir): """Test Job speichern und laden.""" job = scheduler.create_job( trigger=CronTrigger("*/5 * * * *"), actions=EmitEventAction("test_event", {"key": "value"}), config=JobConfig(name="Test Job"), job_id="test-save-load", ) # Neue Scheduler-Instanz config2 = SchedulerConfig( jobs_directory=temp_dir, auto_load=True, ) scheduler2 = Scheduler(config=config2) loaded = scheduler2.load_jobs() assert loaded == 1 loaded_job = scheduler2.get_job("test-save-load") assert loaded_job is not None assert loaded_job.name == "Test Job" def test_context(self, scheduler): """Test Kontext-Verwaltung.""" scheduler.set_context("key1", "value1") scheduler.update_context({"key2": "value2"}) ctx = scheduler.context assert ctx["key1"] == "value1" assert ctx["key2"] == "value2" @pytest.mark.asyncio async def test_scheduler_lifecycle(self, scheduler): """Test Scheduler Start/Stop.""" await scheduler.start() assert scheduler.is_running await scheduler.stop() assert not scheduler.is_running @pytest.mark.asyncio async def test_job_execution(self, scheduler): """Test Job-Ausführung.""" executed = [] async def on_execute(ctx): executed.append(ctx["job_id"]) job = scheduler.create_job( trigger=IntervalTrigger(seconds=1), actions=CallbackAction(on_execute, pass_context=True), config=JobConfig(name="Quick Job"), ) await scheduler.start() await asyncio.sleep(1.5) await scheduler.stop() assert len(executed) >= 1 def test_statistics(self, scheduler): """Test Statistiken.""" scheduler.create_job( trigger=IntervalTrigger(minutes=5), actions=EmitEventAction("test"), ) stats = scheduler.get_statistics() assert stats["total_jobs"] == 1 assert stats["enabled_jobs"] == 1 # ============================================================================= # Script Condition Tests # ============================================================================= class TestScriptCondition: """Tests für ScriptCondition.""" def test_inline_code_true(self): """Test Inline-Code der True zurückgibt.""" cond = ScriptCondition( script_code="result = 5 > 3", expected=True, ) assert cond.evaluate({}) def test_inline_code_false(self): """Test Inline-Code der False zurückgibt.""" cond = ScriptCondition( script_code="result = 5 < 3", expected=True, ) assert not cond.evaluate({}) def test_check_function(self): """Test mit check() Funktion.""" cond = ScriptCondition( script_code=""" def check(): return 42 """, expected=42, ) assert cond.evaluate({}) def test_main_function(self): """Test mit main() Funktion.""" cond = ScriptCondition( script_code=""" def main(): return "hello" """, expected="hello", ) assert cond.evaluate({}) def test_with_context(self): """Test mit Kontext-Zugriff.""" cond = ScriptCondition( script_code="result = context.get('value', 0) > 10", expected=True, pass_context=True, ) assert cond.evaluate({"value": 20}) assert not cond.evaluate({"value": 5}) def test_exit_code(self): """Test mit exit() Code.""" cond = ScriptCondition( script_code="exit(0)", # 0 = True ) assert cond.evaluate({}) cond2 = ScriptCondition( script_code="exit(1)", # 1 = False ) assert not cond2.evaluate({}) def test_script_error(self): """Test bei Script-Fehler.""" cond = ScriptCondition( script_code="raise ValueError('test')", ) assert not cond.evaluate({}) # Fehler = False def test_script_file(self): """Test mit Script-Datei.""" with tempfile.NamedTemporaryFile( mode="w", suffix=".py", delete=False ) as f: f.write("result = True") script_path = f.name try: cond = ScriptCondition(script_path=script_path) assert cond.evaluate({}) finally: os.unlink(script_path) def test_serialization(self): """Test Serialisierung.""" cond = ScriptCondition( script_code="result = True", expected=True, name="test_script", ) data = cond.to_dict() assert data["script_code"] == "result = True" assert data["name"] == "test_script" restored = ScriptCondition.from_dict(data) assert restored.evaluate({}) # ============================================================================= # File Value Condition Tests # ============================================================================= class TestJsonValueCondition: """Tests für JsonValueCondition.""" @pytest.fixture def json_file(self): """Erstellt temporäre JSON-Datei.""" data = { "settings": { "enabled": True, "count": 10, "name": "test", "nested": { "value": 42, }, }, "items": ["a", "b", "c"], } with tempfile.NamedTemporaryFile( mode="w", suffix=".json", delete=False ) as f: json.dump(data, f) path = f.name yield path os.unlink(path) def test_simple_value(self, json_file): """Test einfacher Wert.""" cond = JsonValueCondition(json_file, "settings.enabled", expected=True) assert cond.evaluate({}) def test_nested_value(self, json_file): """Test verschachtelter Wert.""" cond = JsonValueCondition( json_file, "settings.nested.value", expected=42 ) assert cond.evaluate({}) def test_comparison_operator(self, json_file): """Test mit Vergleichsoperator.""" cond = JsonValueCondition( json_file, "settings.count", ComparisonOperator.GREATER, 5, ) assert cond.evaluate({}) cond2 = JsonValueCondition( json_file, "settings.count", ComparisonOperator.LESS, 5, ) assert not cond2.evaluate({}) def test_array_access(self, json_file): """Test Array-Zugriff.""" cond = JsonValueCondition(json_file, "items.0", expected="a") assert cond.evaluate({}) cond2 = JsonValueCondition(json_file, "items.2", expected="c") assert cond2.evaluate({}) def test_default_value(self, json_file): """Test Default-Wert bei fehlendem Key.""" cond = JsonValueCondition( json_file, "settings.nonexistent", expected="default", default="default", ) assert cond.evaluate({}) def test_file_not_found(self): """Test bei nicht existierender Datei.""" cond = JsonValueCondition( "/nonexistent/path.json", "key", expected="value" ) assert not cond.evaluate({}) def test_serialization(self, json_file): """Test Serialisierung.""" cond = JsonValueCondition( json_file, "settings.count", ComparisonOperator.GREATER, 5, name="json_test", ) data = cond.to_dict() assert data["file_path"] == json_file assert data["key_path"] == "settings.count" restored = JsonValueCondition.from_dict(data) assert restored.evaluate({}) class TestIniValueCondition: """Tests für IniValueCondition.""" @pytest.fixture def ini_file(self): """Erstellt temporäre INI-Datei.""" content = """[settings] enabled = true count = 10 name = test [database] host = localhost port = 5432 """ with tempfile.NamedTemporaryFile( mode="w", suffix=".ini", delete=False ) as f: f.write(content) path = f.name yield path os.unlink(path) def test_string_value(self, ini_file): """Test String-Wert.""" cond = IniValueCondition(ini_file, "settings.name", expected="test") assert cond.evaluate({}) def test_boolean_value(self, ini_file): """Test Boolean-Wert.""" cond = IniValueCondition( ini_file, "settings.enabled", expected=True, value_type=bool ) assert cond.evaluate({}) def test_integer_value(self, ini_file): """Test Integer-Wert.""" cond = IniValueCondition( ini_file, "settings.count", ComparisonOperator.GREATER, 5, value_type=int, ) assert cond.evaluate({}) def test_different_section(self, ini_file): """Test andere Section.""" cond = IniValueCondition( ini_file, "database.host", expected="localhost" ) assert cond.evaluate({}) class TestEnvFileValueCondition: """Tests für EnvFileValueCondition.""" @pytest.fixture def env_file(self): """Erstellt temporäre .env-Datei.""" content = """# Comment DEBUG=true API_KEY="secret123" MAX_CONNECTIONS=100 EMPTY_VAR= """ with tempfile.NamedTemporaryFile( mode="w", suffix=".env", delete=False ) as f: f.write(content) path = f.name yield path os.unlink(path) def test_string_value(self, env_file): """Test String-Wert.""" cond = EnvFileValueCondition(env_file, "API_KEY", expected="secret123") assert cond.evaluate({}) def test_boolean_value(self, env_file): """Test Boolean-Wert.""" cond = EnvFileValueCondition( env_file, "DEBUG", expected=True, value_type=bool ) assert cond.evaluate({}) def test_integer_value(self, env_file): """Test Integer-Wert.""" cond = EnvFileValueCondition( env_file, "MAX_CONNECTIONS", ComparisonOperator.GREATER_EQUAL, 50, value_type=int, ) assert cond.evaluate({}) # ============================================================================= # Expression Condition Tests # ============================================================================= class TestExpressionCondition: """Tests für ExpressionCondition.""" def test_simple_expression(self): """Test einfacher Ausdruck.""" cond = ExpressionCondition("len(context.get('items', [])) > 2") assert cond.evaluate({"items": [1, 2, 3]}) assert not cond.evaluate({"items": [1]}) def test_ctx_shortcut(self): """Test ctx Shortcut.""" cond = ExpressionCondition("ctx.get('value', 0) > 10") assert cond.evaluate({"value": 20}) assert not cond.evaluate({"value": 5}) def test_builtin_functions(self): """Test Builtin-Funktionen.""" cond = ExpressionCondition("max(1, 2, 3) == 3") assert cond.evaluate({}) cond2 = ExpressionCondition("sum([1, 2, 3]) == 6") assert cond2.evaluate({}) def test_comparison_operators(self): """Test Vergleichsoperatoren.""" assert ExpressionCondition("5 > 3").evaluate({}) assert ExpressionCondition("5 >= 5").evaluate({}) assert ExpressionCondition("3 < 5").evaluate({}) assert ExpressionCondition("3 <= 3").evaluate({}) assert ExpressionCondition("5 == 5").evaluate({}) assert ExpressionCondition("5 != 3").evaluate({}) def test_logical_operators(self): """Test logische Operatoren.""" cond = ExpressionCondition( "ctx.get('a') and ctx.get('b')" ) assert cond.evaluate({"a": True, "b": True}) assert not cond.evaluate({"a": True, "b": False}) cond2 = ExpressionCondition( "ctx.get('a') or ctx.get('b')" ) assert cond2.evaluate({"a": True, "b": False}) assert cond2.evaluate({"a": False, "b": True}) def test_with_mock_app(self): """Test mit Mock-App.""" class MockSatelliteManager: count = 5 def get_by_room(self, room): return "sat" if room == "kitchen" else None class MockApp: satellite_manager = MockSatelliteManager() cond = ExpressionCondition("satellites.count > 3") assert cond.evaluate({"app": MockApp()}) cond2 = ExpressionCondition( "satellites.get_by_room('kitchen') is not None" ) assert cond2.evaluate({"app": MockApp()}) def test_expression_error(self): """Test bei Ausdruck-Fehler.""" cond = ExpressionCondition("undefined_variable > 5") assert not cond.evaluate({}) # Fehler = False def test_invert(self): """Test invertierter Ausdruck.""" cond = ExpressionCondition("5 > 10", invert=True) assert cond.evaluate({}) # False wird zu True def test_serialization(self): """Test Serialisierung.""" cond = ExpressionCondition( "satellites.count > 0", name="test_expr", ) data = cond.to_dict() assert data["expression"] == "satellites.count > 0" assert data["name"] == "test_expr" restored = ExpressionCondition.from_dict(data) assert restored.expression == cond.expression class TestLambdaCondition: """Tests für LambdaCondition.""" def test_simple_lambda(self): """Test einfache Lambda.""" cond = LambdaCondition(lambda ctx: ctx.get("count", 0) >= 5) assert cond.evaluate({"count": 10}) assert not cond.evaluate({"count": 2}) def test_complex_lambda(self): """Test komplexe Lambda.""" cond = LambdaCondition( lambda ctx: ( ctx.get("a", 0) > 5 and ctx.get("b", "") == "test" and len(ctx.get("items", [])) > 0 ) ) assert cond.evaluate({"a": 10, "b": "test", "items": [1, 2]}) assert not cond.evaluate({"a": 10, "b": "wrong", "items": [1]}) class TestExpressionHelpers: """Tests für Expression Helper-Funktionen.""" def test_satellites_connected(self): """Test satellites_connected Helper.""" cond = satellites_connected(min_count=2) assert cond.expression == "satellites.count >= 2" def test_plugin_enabled(self): """Test plugin_enabled Helper.""" cond = plugin_enabled("spotify") assert cond.expression == "plugins.is_enabled('spotify')" # ============================================================================= # CronPresets Tests # ============================================================================= class TestCronPresets: """Tests für vordefinierte Cron-Ausdrücke.""" def test_presets_valid(self): """Test alle Presets sind gültig.""" presets = [ CronPresets.EVERY_MINUTE, CronPresets.EVERY_5_MINUTES, CronPresets.EVERY_HOUR, CronPresets.DAILY, CronPresets.WEEKLY, CronPresets.MONTHLY, CronPresets.WEEKDAYS, ] for preset in presets: trigger = CronTrigger(preset) assert trigger is not None if __name__ == "__main__": pytest.main([__file__, "-v"])