| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295 |
- # -*- 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"])
|