test_scheduler.py 39 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests für das Scheduler-System.
  4. """
  5. import asyncio
  6. import json
  7. import os
  8. import tempfile
  9. from datetime import datetime, time, timedelta
  10. from pathlib import Path
  11. from unittest.mock import AsyncMock, MagicMock, patch
  12. import pytest
  13. from trixy_core.scheduler import (
  14. # Job
  15. ScheduledJob, JobConfig, JobState, JobResult,
  16. # Triggers
  17. CronTrigger, IntervalTrigger, DateTimeTrigger, EventTrigger,
  18. AndTrigger, OrTrigger, TriggerState,
  19. # Conditions
  20. TimeRangeCondition, DayOfWeekCondition, DateRangeCondition,
  21. RecentActivityCondition, ActivityCountCondition,
  22. SensorThresholdCondition, ComparisonOperator,
  23. StateCondition, BooleanCondition, ExistsCondition,
  24. AndCondition, OrCondition, NotCondition, XorCondition,
  25. # Script & File Conditions
  26. ScriptCondition, AsyncScriptCondition,
  27. JsonValueCondition, IniValueCondition, YamlValueCondition,
  28. EnvFileValueCondition,
  29. # Expression
  30. ExpressionCondition, LambdaCondition,
  31. satellites_connected, plugin_enabled,
  32. # Actions
  33. Action, ActionResult, EmitEventAction, CallbackAction, CommandAction,
  34. # Scheduler
  35. Scheduler, SchedulerConfig,
  36. )
  37. from trixy_core.scheduler.trigger.base import TriggerContext
  38. from trixy_core.scheduler.trigger.cron import CronField, CronExpression, CronPresets
  39. # =============================================================================
  40. # Trigger Tests
  41. # =============================================================================
  42. class TestCronField:
  43. """Tests für CronField."""
  44. def test_wildcard(self):
  45. """Test Wildcard '*'."""
  46. field = CronField("*", 0, 59)
  47. assert field.matches(0)
  48. assert field.matches(30)
  49. assert field.matches(59)
  50. def test_single_value(self):
  51. """Test einzelner Wert."""
  52. field = CronField("15", 0, 59)
  53. assert not field.matches(0)
  54. assert field.matches(15)
  55. assert not field.matches(30)
  56. def test_range(self):
  57. """Test Range '1-5'."""
  58. field = CronField("1-5", 0, 10)
  59. assert not field.matches(0)
  60. assert field.matches(1)
  61. assert field.matches(3)
  62. assert field.matches(5)
  63. assert not field.matches(6)
  64. def test_step(self):
  65. """Test Step '*/15'."""
  66. field = CronField("*/15", 0, 59)
  67. assert field.matches(0)
  68. assert field.matches(15)
  69. assert field.matches(30)
  70. assert field.matches(45)
  71. assert not field.matches(10)
  72. def test_list(self):
  73. """Test Liste '1,5,10'."""
  74. field = CronField("1,5,10", 0, 59)
  75. assert field.matches(1)
  76. assert field.matches(5)
  77. assert field.matches(10)
  78. assert not field.matches(2)
  79. def test_aliases(self):
  80. """Test Aliase für Wochentage."""
  81. field = CronField("MON", 0, 6, {"MON": 1, "TUE": 2})
  82. assert field.matches(1)
  83. assert not field.matches(0)
  84. class TestCronTrigger:
  85. """Tests für CronTrigger."""
  86. def test_every_minute(self):
  87. """Test jede Minute."""
  88. trigger = CronTrigger("* * * * *")
  89. ctx = TriggerContext(current_time=datetime(2024, 1, 15, 10, 30, 0))
  90. assert trigger.should_fire(ctx)
  91. def test_specific_time(self):
  92. """Test spezifische Zeit."""
  93. trigger = CronTrigger("30 10 * * *") # 10:30
  94. # Match
  95. ctx1 = TriggerContext(current_time=datetime(2024, 1, 15, 10, 30, 0))
  96. assert trigger.should_fire(ctx1)
  97. # No match
  98. ctx2 = TriggerContext(current_time=datetime(2024, 1, 15, 10, 31, 0))
  99. assert not trigger.should_fire(ctx2)
  100. def test_weekdays(self):
  101. """Test Werktage."""
  102. trigger = CronTrigger("0 9 * * 1-5") # 9:00, Mo-Fr
  103. # Montag
  104. ctx1 = TriggerContext(current_time=datetime(2024, 1, 15, 9, 0, 0)) # Montag
  105. assert trigger.should_fire(ctx1)
  106. # Samstag
  107. ctx2 = TriggerContext(current_time=datetime(2024, 1, 20, 9, 0, 0)) # Samstag
  108. assert not trigger.should_fire(ctx2)
  109. def test_get_next_fire_time(self):
  110. """Test nächste Auslösezeit."""
  111. trigger = CronTrigger("0 * * * *") # Jede volle Stunde
  112. now = datetime(2024, 1, 15, 10, 30, 0)
  113. next_time = trigger.get_next_fire_time(now)
  114. assert next_time is not None
  115. assert next_time.minute == 0
  116. assert next_time.hour == 11
  117. def test_serialization(self):
  118. """Test Serialisierung."""
  119. trigger = CronTrigger("*/5 * * * *", name="Test")
  120. data = trigger.to_dict()
  121. assert data["expression"] == "*/5 * * * *"
  122. assert data["name"] == "Test"
  123. restored = CronTrigger.from_dict(data)
  124. assert restored.expression == trigger.expression
  125. class TestIntervalTrigger:
  126. """Tests für IntervalTrigger."""
  127. def test_basic_interval(self):
  128. """Test einfaches Intervall."""
  129. trigger = IntervalTrigger(minutes=5)
  130. assert trigger.interval_seconds == 300
  131. def test_should_fire_first_time(self):
  132. """Test erste Auslösung."""
  133. trigger = IntervalTrigger(minutes=5)
  134. ctx = TriggerContext(current_time=datetime.now())
  135. assert trigger.should_fire(ctx)
  136. def test_should_fire_after_interval(self):
  137. """Test Auslösung nach Intervall."""
  138. trigger = IntervalTrigger(minutes=5)
  139. now = datetime.now()
  140. past = now - timedelta(minutes=6)
  141. ctx = TriggerContext(current_time=now, last_fire_time=past)
  142. assert trigger.should_fire(ctx)
  143. def test_should_not_fire_before_interval(self):
  144. """Test keine Auslösung vor Intervall."""
  145. trigger = IntervalTrigger(minutes=5)
  146. now = datetime.now()
  147. recent = now - timedelta(minutes=2)
  148. ctx = TriggerContext(current_time=now, last_fire_time=recent)
  149. assert not trigger.should_fire(ctx)
  150. def test_end_time(self):
  151. """Test Endzeit."""
  152. end = datetime.now() - timedelta(hours=1)
  153. trigger = IntervalTrigger(minutes=5, end_time=end)
  154. ctx = TriggerContext(current_time=datetime.now())
  155. assert not trigger.should_fire(ctx)
  156. assert trigger.state == TriggerState.FINISHED
  157. class TestDateTimeTrigger:
  158. """Tests für DateTimeTrigger."""
  159. def test_future_trigger(self):
  160. """Test zukünftiger Trigger."""
  161. future = datetime.now() + timedelta(hours=1)
  162. trigger = DateTimeTrigger(future)
  163. ctx = TriggerContext(current_time=datetime.now())
  164. assert not trigger.should_fire(ctx)
  165. def test_trigger_at_time(self):
  166. """Test Auslösung zum Zeitpunkt."""
  167. now = datetime.now()
  168. trigger = DateTimeTrigger(now, tolerance_seconds=5)
  169. ctx = TriggerContext(current_time=now)
  170. assert trigger.should_fire(ctx)
  171. def test_trigger_finished_after_fire(self):
  172. """Test Trigger beendet nach Auslösung."""
  173. now = datetime.now()
  174. trigger = DateTimeTrigger(now)
  175. trigger.fire()
  176. assert trigger.state == TriggerState.FINISHED
  177. assert trigger.get_next_fire_time() is None
  178. def test_helper_methods(self):
  179. """Test Helper-Methoden."""
  180. trigger = DateTimeTrigger.in_minutes(30)
  181. assert trigger.run_at > datetime.now()
  182. trigger2 = DateTimeTrigger.at_time(14, 30)
  183. assert trigger2.run_at.hour == 14
  184. assert trigger2.run_at.minute == 30
  185. class TestEventTrigger:
  186. """Tests für EventTrigger."""
  187. def test_single_event(self):
  188. """Test einzelnes Event."""
  189. trigger = EventTrigger("test_event")
  190. assert "test_event" in trigger.event_names
  191. def test_multiple_events(self):
  192. """Test mehrere Events."""
  193. trigger = EventTrigger(["event_a", "event_b"])
  194. assert len(trigger.event_names) == 2
  195. def test_should_fire_on_event(self):
  196. """Test Auslösung bei Event."""
  197. trigger = EventTrigger("test_event")
  198. ctx = TriggerContext(
  199. current_time=datetime.now(),
  200. event_name="test_event",
  201. event_data={"key": "value"},
  202. )
  203. assert trigger.should_fire(ctx)
  204. def test_should_not_fire_on_other_event(self):
  205. """Test keine Auslösung bei anderem Event."""
  206. trigger = EventTrigger("test_event")
  207. ctx = TriggerContext(
  208. current_time=datetime.now(),
  209. event_name="other_event",
  210. )
  211. assert not trigger.should_fire(ctx)
  212. def test_filter_function(self):
  213. """Test Filter-Funktion."""
  214. trigger = EventTrigger(
  215. "test_event",
  216. filter_fn=lambda d: d.get("type") == "important",
  217. )
  218. # Matches
  219. ctx1 = TriggerContext(
  220. current_time=datetime.now(),
  221. event_name="test_event",
  222. event_data={"type": "important"},
  223. )
  224. assert trigger.should_fire(ctx1)
  225. # No match
  226. ctx2 = TriggerContext(
  227. current_time=datetime.now(),
  228. event_name="test_event",
  229. event_data={"type": "normal"},
  230. )
  231. assert not trigger.should_fire(ctx2)
  232. class TestCompositeTriggers:
  233. """Tests für Composite Triggers."""
  234. def test_and_trigger(self):
  235. """Test AND Trigger."""
  236. # Beide müssen feuern
  237. trigger1 = EventTrigger("event_a")
  238. trigger2 = EventTrigger("event_b")
  239. and_trigger = AndTrigger([trigger1, trigger2])
  240. # Nur event_a
  241. ctx1 = TriggerContext(
  242. current_time=datetime.now(),
  243. event_name="event_a",
  244. )
  245. assert not and_trigger.should_fire(ctx1)
  246. def test_or_trigger(self):
  247. """Test OR Trigger."""
  248. trigger1 = CronTrigger("30 10 * * *") # 10:30
  249. trigger2 = CronTrigger("30 14 * * *") # 14:30
  250. or_trigger = OrTrigger([trigger1, trigger2])
  251. # 10:30 match
  252. ctx = TriggerContext(current_time=datetime(2024, 1, 15, 10, 30, 0))
  253. assert or_trigger.should_fire(ctx)
  254. # =============================================================================
  255. # Condition Tests
  256. # =============================================================================
  257. class TestTimeConditions:
  258. """Tests für Zeit-Conditions."""
  259. def test_time_range_normal(self):
  260. """Test normales Zeitfenster."""
  261. cond = TimeRangeCondition(time(9, 0), time(17, 0))
  262. # In range
  263. ctx1 = {"current_time": datetime(2024, 1, 15, 12, 0, 0)}
  264. assert cond.evaluate(ctx1)
  265. # Out of range
  266. ctx2 = {"current_time": datetime(2024, 1, 15, 20, 0, 0)}
  267. assert not cond.evaluate(ctx2)
  268. def test_time_range_overnight(self):
  269. """Test Zeitfenster über Mitternacht."""
  270. cond = TimeRangeCondition(time(22, 0), time(6, 0))
  271. # 23:00 - in range
  272. ctx1 = {"current_time": datetime(2024, 1, 15, 23, 0, 0)}
  273. assert cond.evaluate(ctx1)
  274. # 03:00 - in range
  275. ctx2 = {"current_time": datetime(2024, 1, 15, 3, 0, 0)}
  276. assert cond.evaluate(ctx2)
  277. # 12:00 - out of range
  278. ctx3 = {"current_time": datetime(2024, 1, 15, 12, 0, 0)}
  279. assert not cond.evaluate(ctx3)
  280. def test_day_of_week(self):
  281. """Test Wochentag-Condition."""
  282. cond = DayOfWeekCondition.weekdays()
  283. # Montag
  284. ctx1 = {"current_time": datetime(2024, 1, 15, 12, 0, 0)} # Montag
  285. assert cond.evaluate(ctx1)
  286. # Samstag
  287. ctx2 = {"current_time": datetime(2024, 1, 20, 12, 0, 0)} # Samstag
  288. assert not cond.evaluate(ctx2)
  289. class TestActivityConditions:
  290. """Tests für Aktivitäts-Conditions."""
  291. def test_recent_activity(self):
  292. """Test kürzliche Aktivität."""
  293. cond = RecentActivityCondition("last_voice_input", minutes=10)
  294. now = datetime.now()
  295. # Recent - 5 minutes ago
  296. ctx1 = {
  297. "current_time": now,
  298. "last_voice_input": now - timedelta(minutes=5),
  299. }
  300. assert cond.evaluate(ctx1)
  301. # Not recent - 15 minutes ago
  302. ctx2 = {
  303. "current_time": now,
  304. "last_voice_input": now - timedelta(minutes=15),
  305. }
  306. assert not cond.evaluate(ctx2)
  307. def test_activity_count(self):
  308. """Test Aktivitäts-Zähler."""
  309. cond = ActivityCountCondition("error_count", min_count=5)
  310. assert cond.evaluate({"error_count": 10})
  311. assert cond.evaluate({"error_count": 5})
  312. assert not cond.evaluate({"error_count": 3})
  313. class TestSensorConditions:
  314. """Tests für Sensor-Conditions."""
  315. def test_threshold_greater(self):
  316. """Test Schwellenwert größer."""
  317. cond = SensorThresholdCondition(
  318. "temperature",
  319. ComparisonOperator.GREATER,
  320. 25,
  321. )
  322. assert cond.evaluate({"temperature": 30})
  323. assert not cond.evaluate({"temperature": 20})
  324. def test_threshold_between(self):
  325. """Test Schwellenwert zwischen."""
  326. cond = SensorThresholdCondition(
  327. "volume",
  328. ComparisonOperator.BETWEEN,
  329. (0.5, 0.8),
  330. )
  331. assert cond.evaluate({"volume": 0.6})
  332. assert not cond.evaluate({"volume": 0.9})
  333. def test_threshold_in_list(self):
  334. """Test Wert in Liste."""
  335. cond = SensorThresholdCondition(
  336. "status",
  337. ComparisonOperator.IN,
  338. ["active", "standby"],
  339. )
  340. assert cond.evaluate({"status": "active"})
  341. assert not cond.evaluate({"status": "offline"})
  342. class TestStateConditions:
  343. """Tests für Zustands-Conditions."""
  344. def test_state_condition(self):
  345. """Test Zustands-Condition."""
  346. cond = StateCondition("system_mode", "active")
  347. assert cond.evaluate({"system_mode": "active"})
  348. assert cond.evaluate({"system_mode": "ACTIVE"}) # Case-insensitive
  349. assert not cond.evaluate({"system_mode": "standby"})
  350. def test_boolean_condition(self):
  351. """Test Boolean-Condition."""
  352. cond = BooleanCondition("is_home")
  353. assert cond.evaluate({"is_home": True})
  354. assert not cond.evaluate({"is_home": False})
  355. assert not cond.evaluate({})
  356. def test_exists_condition(self):
  357. """Test Exists-Condition."""
  358. cond = ExistsCondition("user_data")
  359. assert cond.evaluate({"user_data": {"name": "Test"}})
  360. assert not cond.evaluate({})
  361. assert not cond.evaluate({"user_data": None})
  362. class TestCompositeConditions:
  363. """Tests für Composite Conditions."""
  364. def test_and_condition(self):
  365. """Test AND Condition."""
  366. cond = AndCondition([
  367. BooleanCondition("flag_a"),
  368. BooleanCondition("flag_b"),
  369. ])
  370. assert cond.evaluate({"flag_a": True, "flag_b": True})
  371. assert not cond.evaluate({"flag_a": True, "flag_b": False})
  372. def test_or_condition(self):
  373. """Test OR Condition."""
  374. cond = OrCondition([
  375. BooleanCondition("flag_a"),
  376. BooleanCondition("flag_b"),
  377. ])
  378. assert cond.evaluate({"flag_a": True, "flag_b": False})
  379. assert cond.evaluate({"flag_a": False, "flag_b": True})
  380. assert not cond.evaluate({"flag_a": False, "flag_b": False})
  381. def test_not_condition(self):
  382. """Test NOT Condition."""
  383. cond = NotCondition(BooleanCondition("maintenance_mode"))
  384. assert cond.evaluate({"maintenance_mode": False})
  385. assert not cond.evaluate({"maintenance_mode": True})
  386. def test_xor_condition(self):
  387. """Test XOR Condition."""
  388. cond = XorCondition([
  389. BooleanCondition("flag_a"),
  390. BooleanCondition("flag_b"),
  391. ])
  392. assert cond.evaluate({"flag_a": True, "flag_b": False})
  393. assert cond.evaluate({"flag_a": False, "flag_b": True})
  394. assert not cond.evaluate({"flag_a": True, "flag_b": True})
  395. assert not cond.evaluate({"flag_a": False, "flag_b": False})
  396. def test_inverted_condition(self):
  397. """Test invertierte Condition."""
  398. cond = BooleanCondition("flag", invert=True)
  399. assert cond.evaluate({"flag": False})
  400. assert not cond.evaluate({"flag": True})
  401. # =============================================================================
  402. # Action Tests
  403. # =============================================================================
  404. class TestEmitEventAction:
  405. """Tests für EmitEventAction."""
  406. @pytest.mark.asyncio
  407. async def test_basic_emit(self):
  408. """Test einfaches Event-Emit."""
  409. action = EmitEventAction("test_event", {"key": "value"})
  410. result = await action.execute({})
  411. assert result.success
  412. assert result.result["event_name"] == "test_event"
  413. assert result.result["event_data"]["key"] == "value"
  414. @pytest.mark.asyncio
  415. async def test_emit_with_event_manager(self):
  416. """Test Emit mit EventManager."""
  417. mock_em = AsyncMock()
  418. action = EmitEventAction("test_event", {"key": "value"})
  419. await action.execute({"event_manager": mock_em})
  420. mock_em.emit.assert_called_once()
  421. class TestCallbackAction:
  422. """Tests für CallbackAction."""
  423. @pytest.mark.asyncio
  424. async def test_sync_callback(self):
  425. """Test synchroner Callback."""
  426. def my_func(x, y):
  427. return x + y
  428. action = CallbackAction(my_func, args=(2, 3))
  429. result = await action.execute({})
  430. assert result.success
  431. assert result.result == 5
  432. @pytest.mark.asyncio
  433. async def test_async_callback(self):
  434. """Test asynchroner Callback."""
  435. async def my_async_func(x):
  436. await asyncio.sleep(0.01)
  437. return x * 2
  438. action = CallbackAction(my_async_func, args=(5,))
  439. result = await action.execute({})
  440. assert result.success
  441. assert result.result == 10
  442. @pytest.mark.asyncio
  443. async def test_callback_with_context(self):
  444. """Test Callback mit Kontext."""
  445. def my_func(ctx):
  446. return ctx.get("value", 0) * 2
  447. action = CallbackAction(my_func, pass_context=True)
  448. result = await action.execute({"value": 10})
  449. assert result.success
  450. assert result.result == 20
  451. class TestCommandAction:
  452. """Tests für CommandAction."""
  453. @pytest.mark.asyncio
  454. async def test_simple_command(self):
  455. """Test einfacher Befehl."""
  456. action = CommandAction("echo 'Hello World'", shell=True)
  457. result = await action.execute({})
  458. assert result.success
  459. assert "Hello World" in result.result["stdout"]
  460. @pytest.mark.asyncio
  461. async def test_command_list(self):
  462. """Test Befehl als Liste."""
  463. action = CommandAction(["echo", "Hello"])
  464. result = await action.execute({})
  465. assert result.success
  466. assert "Hello" in result.result["stdout"]
  467. @pytest.mark.asyncio
  468. async def test_command_failure(self):
  469. """Test fehlgeschlagener Befehl."""
  470. action = CommandAction("exit 1", shell=True)
  471. result = await action.execute({})
  472. assert not result.success
  473. assert result.error is not None
  474. # =============================================================================
  475. # Job Tests
  476. # =============================================================================
  477. class TestScheduledJob:
  478. """Tests für ScheduledJob."""
  479. def test_create_job(self):
  480. """Test Job-Erstellung."""
  481. trigger = IntervalTrigger(minutes=5)
  482. action = EmitEventAction("test_event")
  483. config = JobConfig(name="Test Job")
  484. job = ScheduledJob(
  485. trigger=trigger,
  486. actions=[action],
  487. config=config,
  488. )
  489. assert job.name == "Test Job"
  490. assert job.is_enabled
  491. assert job.state == JobState.PENDING
  492. def test_job_conditions(self):
  493. """Test Job mit Conditions."""
  494. job = ScheduledJob()
  495. cond = BooleanCondition("flag")
  496. job.add_condition(cond)
  497. assert len(job.conditions) == 1
  498. assert job.should_run_now({"flag": True})
  499. assert not job.should_run_now({"flag": False})
  500. def test_job_enable_disable(self):
  501. """Test Job aktivieren/deaktivieren."""
  502. job = ScheduledJob()
  503. job.disable()
  504. assert not job.is_enabled
  505. assert job.state == JobState.DISABLED
  506. job.enable()
  507. assert job.is_enabled
  508. def test_job_record_run(self):
  509. """Test Ausführungs-Aufzeichnung."""
  510. job = ScheduledJob()
  511. result = JobResult(
  512. job_id=job.job_id,
  513. success=True,
  514. start_time=datetime.now(),
  515. end_time=datetime.now(),
  516. )
  517. job.record_run(result)
  518. assert job.run_count == 1
  519. assert job.failure_count == 0
  520. assert job.last_result == result
  521. def test_job_max_failures(self):
  522. """Test maximale Fehler."""
  523. config = JobConfig(max_failures=2)
  524. job = ScheduledJob(config=config)
  525. for i in range(2):
  526. result = JobResult(
  527. job_id=job.job_id,
  528. success=False,
  529. start_time=datetime.now(),
  530. end_time=datetime.now(),
  531. error="Test error",
  532. )
  533. job.record_run(result)
  534. assert job.state == JobState.DISABLED
  535. def test_job_serialization(self):
  536. """Test Job-Serialisierung."""
  537. trigger = CronTrigger("0 * * * *")
  538. action = EmitEventAction("test_event")
  539. config = JobConfig(name="Test", description="Test Job")
  540. job = ScheduledJob(
  541. job_id="test-job-1",
  542. trigger=trigger,
  543. actions=[action],
  544. config=config,
  545. )
  546. data = job.to_dict()
  547. assert data["job_id"] == "test-job-1"
  548. assert data["name"] == "Test"
  549. assert data["trigger"]["expression"] == "0 * * * *"
  550. # =============================================================================
  551. # Scheduler Tests
  552. # =============================================================================
  553. class TestScheduler:
  554. """Tests für Scheduler."""
  555. @pytest.fixture
  556. def temp_dir(self):
  557. """Temporäres Verzeichnis für Tests."""
  558. with tempfile.TemporaryDirectory() as td:
  559. yield td
  560. @pytest.fixture
  561. def scheduler(self, temp_dir):
  562. """Scheduler-Instanz für Tests."""
  563. config = SchedulerConfig(
  564. jobs_directory=temp_dir,
  565. auto_load=False,
  566. auto_save=True,
  567. )
  568. return Scheduler(config=config)
  569. def test_create_scheduler(self, scheduler):
  570. """Test Scheduler-Erstellung."""
  571. assert scheduler.job_count == 0
  572. assert not scheduler.is_running
  573. def test_add_job(self, scheduler):
  574. """Test Job hinzufügen."""
  575. trigger = IntervalTrigger(minutes=5)
  576. action = EmitEventAction("test_event")
  577. job = scheduler.create_job(
  578. trigger=trigger,
  579. actions=action,
  580. config=JobConfig(name="Test"),
  581. )
  582. assert scheduler.job_count == 1
  583. assert scheduler.get_job(job.job_id) is not None
  584. def test_remove_job(self, scheduler):
  585. """Test Job entfernen."""
  586. job = scheduler.create_job(
  587. trigger=IntervalTrigger(minutes=5),
  588. actions=EmitEventAction("test"),
  589. )
  590. job_id = job.job_id
  591. assert scheduler.remove_job(job_id)
  592. assert scheduler.get_job(job_id) is None
  593. def test_save_load_job(self, scheduler, temp_dir):
  594. """Test Job speichern und laden."""
  595. job = scheduler.create_job(
  596. trigger=CronTrigger("*/5 * * * *"),
  597. actions=EmitEventAction("test_event", {"key": "value"}),
  598. config=JobConfig(name="Test Job"),
  599. job_id="test-save-load",
  600. )
  601. # Neue Scheduler-Instanz
  602. config2 = SchedulerConfig(
  603. jobs_directory=temp_dir,
  604. auto_load=True,
  605. )
  606. scheduler2 = Scheduler(config=config2)
  607. loaded = scheduler2.load_jobs()
  608. assert loaded == 1
  609. loaded_job = scheduler2.get_job("test-save-load")
  610. assert loaded_job is not None
  611. assert loaded_job.name == "Test Job"
  612. def test_context(self, scheduler):
  613. """Test Kontext-Verwaltung."""
  614. scheduler.set_context("key1", "value1")
  615. scheduler.update_context({"key2": "value2"})
  616. ctx = scheduler.context
  617. assert ctx["key1"] == "value1"
  618. assert ctx["key2"] == "value2"
  619. @pytest.mark.asyncio
  620. async def test_scheduler_lifecycle(self, scheduler):
  621. """Test Scheduler Start/Stop."""
  622. await scheduler.start()
  623. assert scheduler.is_running
  624. await scheduler.stop()
  625. assert not scheduler.is_running
  626. @pytest.mark.asyncio
  627. async def test_job_execution(self, scheduler):
  628. """Test Job-Ausführung."""
  629. executed = []
  630. async def on_execute(ctx):
  631. executed.append(ctx["job_id"])
  632. job = scheduler.create_job(
  633. trigger=IntervalTrigger(seconds=1),
  634. actions=CallbackAction(on_execute, pass_context=True),
  635. config=JobConfig(name="Quick Job"),
  636. )
  637. await scheduler.start()
  638. await asyncio.sleep(1.5)
  639. await scheduler.stop()
  640. assert len(executed) >= 1
  641. def test_statistics(self, scheduler):
  642. """Test Statistiken."""
  643. scheduler.create_job(
  644. trigger=IntervalTrigger(minutes=5),
  645. actions=EmitEventAction("test"),
  646. )
  647. stats = scheduler.get_statistics()
  648. assert stats["total_jobs"] == 1
  649. assert stats["enabled_jobs"] == 1
  650. # =============================================================================
  651. # Script Condition Tests
  652. # =============================================================================
  653. class TestScriptCondition:
  654. """Tests für ScriptCondition."""
  655. def test_inline_code_true(self):
  656. """Test Inline-Code der True zurückgibt."""
  657. cond = ScriptCondition(
  658. script_code="result = 5 > 3",
  659. expected=True,
  660. )
  661. assert cond.evaluate({})
  662. def test_inline_code_false(self):
  663. """Test Inline-Code der False zurückgibt."""
  664. cond = ScriptCondition(
  665. script_code="result = 5 < 3",
  666. expected=True,
  667. )
  668. assert not cond.evaluate({})
  669. def test_check_function(self):
  670. """Test mit check() Funktion."""
  671. cond = ScriptCondition(
  672. script_code="""
  673. def check():
  674. return 42
  675. """,
  676. expected=42,
  677. )
  678. assert cond.evaluate({})
  679. def test_main_function(self):
  680. """Test mit main() Funktion."""
  681. cond = ScriptCondition(
  682. script_code="""
  683. def main():
  684. return "hello"
  685. """,
  686. expected="hello",
  687. )
  688. assert cond.evaluate({})
  689. def test_with_context(self):
  690. """Test mit Kontext-Zugriff."""
  691. cond = ScriptCondition(
  692. script_code="result = context.get('value', 0) > 10",
  693. expected=True,
  694. pass_context=True,
  695. )
  696. assert cond.evaluate({"value": 20})
  697. assert not cond.evaluate({"value": 5})
  698. def test_exit_code(self):
  699. """Test mit exit() Code."""
  700. cond = ScriptCondition(
  701. script_code="exit(0)", # 0 = True
  702. )
  703. assert cond.evaluate({})
  704. cond2 = ScriptCondition(
  705. script_code="exit(1)", # 1 = False
  706. )
  707. assert not cond2.evaluate({})
  708. def test_script_error(self):
  709. """Test bei Script-Fehler."""
  710. cond = ScriptCondition(
  711. script_code="raise ValueError('test')",
  712. )
  713. assert not cond.evaluate({}) # Fehler = False
  714. def test_script_file(self):
  715. """Test mit Script-Datei."""
  716. with tempfile.NamedTemporaryFile(
  717. mode="w", suffix=".py", delete=False
  718. ) as f:
  719. f.write("result = True")
  720. script_path = f.name
  721. try:
  722. cond = ScriptCondition(script_path=script_path)
  723. assert cond.evaluate({})
  724. finally:
  725. os.unlink(script_path)
  726. def test_serialization(self):
  727. """Test Serialisierung."""
  728. cond = ScriptCondition(
  729. script_code="result = True",
  730. expected=True,
  731. name="test_script",
  732. )
  733. data = cond.to_dict()
  734. assert data["script_code"] == "result = True"
  735. assert data["name"] == "test_script"
  736. restored = ScriptCondition.from_dict(data)
  737. assert restored.evaluate({})
  738. # =============================================================================
  739. # File Value Condition Tests
  740. # =============================================================================
  741. class TestJsonValueCondition:
  742. """Tests für JsonValueCondition."""
  743. @pytest.fixture
  744. def json_file(self):
  745. """Erstellt temporäre JSON-Datei."""
  746. data = {
  747. "settings": {
  748. "enabled": True,
  749. "count": 10,
  750. "name": "test",
  751. "nested": {
  752. "value": 42,
  753. },
  754. },
  755. "items": ["a", "b", "c"],
  756. }
  757. with tempfile.NamedTemporaryFile(
  758. mode="w", suffix=".json", delete=False
  759. ) as f:
  760. json.dump(data, f)
  761. path = f.name
  762. yield path
  763. os.unlink(path)
  764. def test_simple_value(self, json_file):
  765. """Test einfacher Wert."""
  766. cond = JsonValueCondition(json_file, "settings.enabled", expected=True)
  767. assert cond.evaluate({})
  768. def test_nested_value(self, json_file):
  769. """Test verschachtelter Wert."""
  770. cond = JsonValueCondition(
  771. json_file, "settings.nested.value", expected=42
  772. )
  773. assert cond.evaluate({})
  774. def test_comparison_operator(self, json_file):
  775. """Test mit Vergleichsoperator."""
  776. cond = JsonValueCondition(
  777. json_file,
  778. "settings.count",
  779. ComparisonOperator.GREATER,
  780. 5,
  781. )
  782. assert cond.evaluate({})
  783. cond2 = JsonValueCondition(
  784. json_file,
  785. "settings.count",
  786. ComparisonOperator.LESS,
  787. 5,
  788. )
  789. assert not cond2.evaluate({})
  790. def test_array_access(self, json_file):
  791. """Test Array-Zugriff."""
  792. cond = JsonValueCondition(json_file, "items.0", expected="a")
  793. assert cond.evaluate({})
  794. cond2 = JsonValueCondition(json_file, "items.2", expected="c")
  795. assert cond2.evaluate({})
  796. def test_default_value(self, json_file):
  797. """Test Default-Wert bei fehlendem Key."""
  798. cond = JsonValueCondition(
  799. json_file,
  800. "settings.nonexistent",
  801. expected="default",
  802. default="default",
  803. )
  804. assert cond.evaluate({})
  805. def test_file_not_found(self):
  806. """Test bei nicht existierender Datei."""
  807. cond = JsonValueCondition(
  808. "/nonexistent/path.json", "key", expected="value"
  809. )
  810. assert not cond.evaluate({})
  811. def test_serialization(self, json_file):
  812. """Test Serialisierung."""
  813. cond = JsonValueCondition(
  814. json_file,
  815. "settings.count",
  816. ComparisonOperator.GREATER,
  817. 5,
  818. name="json_test",
  819. )
  820. data = cond.to_dict()
  821. assert data["file_path"] == json_file
  822. assert data["key_path"] == "settings.count"
  823. restored = JsonValueCondition.from_dict(data)
  824. assert restored.evaluate({})
  825. class TestIniValueCondition:
  826. """Tests für IniValueCondition."""
  827. @pytest.fixture
  828. def ini_file(self):
  829. """Erstellt temporäre INI-Datei."""
  830. content = """[settings]
  831. enabled = true
  832. count = 10
  833. name = test
  834. [database]
  835. host = localhost
  836. port = 5432
  837. """
  838. with tempfile.NamedTemporaryFile(
  839. mode="w", suffix=".ini", delete=False
  840. ) as f:
  841. f.write(content)
  842. path = f.name
  843. yield path
  844. os.unlink(path)
  845. def test_string_value(self, ini_file):
  846. """Test String-Wert."""
  847. cond = IniValueCondition(ini_file, "settings.name", expected="test")
  848. assert cond.evaluate({})
  849. def test_boolean_value(self, ini_file):
  850. """Test Boolean-Wert."""
  851. cond = IniValueCondition(
  852. ini_file, "settings.enabled", expected=True, value_type=bool
  853. )
  854. assert cond.evaluate({})
  855. def test_integer_value(self, ini_file):
  856. """Test Integer-Wert."""
  857. cond = IniValueCondition(
  858. ini_file,
  859. "settings.count",
  860. ComparisonOperator.GREATER,
  861. 5,
  862. value_type=int,
  863. )
  864. assert cond.evaluate({})
  865. def test_different_section(self, ini_file):
  866. """Test andere Section."""
  867. cond = IniValueCondition(
  868. ini_file, "database.host", expected="localhost"
  869. )
  870. assert cond.evaluate({})
  871. class TestEnvFileValueCondition:
  872. """Tests für EnvFileValueCondition."""
  873. @pytest.fixture
  874. def env_file(self):
  875. """Erstellt temporäre .env-Datei."""
  876. content = """# Comment
  877. DEBUG=true
  878. API_KEY="secret123"
  879. MAX_CONNECTIONS=100
  880. EMPTY_VAR=
  881. """
  882. with tempfile.NamedTemporaryFile(
  883. mode="w", suffix=".env", delete=False
  884. ) as f:
  885. f.write(content)
  886. path = f.name
  887. yield path
  888. os.unlink(path)
  889. def test_string_value(self, env_file):
  890. """Test String-Wert."""
  891. cond = EnvFileValueCondition(env_file, "API_KEY", expected="secret123")
  892. assert cond.evaluate({})
  893. def test_boolean_value(self, env_file):
  894. """Test Boolean-Wert."""
  895. cond = EnvFileValueCondition(
  896. env_file, "DEBUG", expected=True, value_type=bool
  897. )
  898. assert cond.evaluate({})
  899. def test_integer_value(self, env_file):
  900. """Test Integer-Wert."""
  901. cond = EnvFileValueCondition(
  902. env_file,
  903. "MAX_CONNECTIONS",
  904. ComparisonOperator.GREATER_EQUAL,
  905. 50,
  906. value_type=int,
  907. )
  908. assert cond.evaluate({})
  909. # =============================================================================
  910. # Expression Condition Tests
  911. # =============================================================================
  912. class TestExpressionCondition:
  913. """Tests für ExpressionCondition."""
  914. def test_simple_expression(self):
  915. """Test einfacher Ausdruck."""
  916. cond = ExpressionCondition("len(context.get('items', [])) > 2")
  917. assert cond.evaluate({"items": [1, 2, 3]})
  918. assert not cond.evaluate({"items": [1]})
  919. def test_ctx_shortcut(self):
  920. """Test ctx Shortcut."""
  921. cond = ExpressionCondition("ctx.get('value', 0) > 10")
  922. assert cond.evaluate({"value": 20})
  923. assert not cond.evaluate({"value": 5})
  924. def test_builtin_functions(self):
  925. """Test Builtin-Funktionen."""
  926. cond = ExpressionCondition("max(1, 2, 3) == 3")
  927. assert cond.evaluate({})
  928. cond2 = ExpressionCondition("sum([1, 2, 3]) == 6")
  929. assert cond2.evaluate({})
  930. def test_comparison_operators(self):
  931. """Test Vergleichsoperatoren."""
  932. assert ExpressionCondition("5 > 3").evaluate({})
  933. assert ExpressionCondition("5 >= 5").evaluate({})
  934. assert ExpressionCondition("3 < 5").evaluate({})
  935. assert ExpressionCondition("3 <= 3").evaluate({})
  936. assert ExpressionCondition("5 == 5").evaluate({})
  937. assert ExpressionCondition("5 != 3").evaluate({})
  938. def test_logical_operators(self):
  939. """Test logische Operatoren."""
  940. cond = ExpressionCondition(
  941. "ctx.get('a') and ctx.get('b')"
  942. )
  943. assert cond.evaluate({"a": True, "b": True})
  944. assert not cond.evaluate({"a": True, "b": False})
  945. cond2 = ExpressionCondition(
  946. "ctx.get('a') or ctx.get('b')"
  947. )
  948. assert cond2.evaluate({"a": True, "b": False})
  949. assert cond2.evaluate({"a": False, "b": True})
  950. def test_with_mock_app(self):
  951. """Test mit Mock-App."""
  952. class MockSatelliteManager:
  953. count = 5
  954. def get_by_room(self, room):
  955. return "sat" if room == "kitchen" else None
  956. class MockApp:
  957. satellite_manager = MockSatelliteManager()
  958. cond = ExpressionCondition("satellites.count > 3")
  959. assert cond.evaluate({"app": MockApp()})
  960. cond2 = ExpressionCondition(
  961. "satellites.get_by_room('kitchen') is not None"
  962. )
  963. assert cond2.evaluate({"app": MockApp()})
  964. def test_expression_error(self):
  965. """Test bei Ausdruck-Fehler."""
  966. cond = ExpressionCondition("undefined_variable > 5")
  967. assert not cond.evaluate({}) # Fehler = False
  968. def test_invert(self):
  969. """Test invertierter Ausdruck."""
  970. cond = ExpressionCondition("5 > 10", invert=True)
  971. assert cond.evaluate({}) # False wird zu True
  972. def test_serialization(self):
  973. """Test Serialisierung."""
  974. cond = ExpressionCondition(
  975. "satellites.count > 0",
  976. name="test_expr",
  977. )
  978. data = cond.to_dict()
  979. assert data["expression"] == "satellites.count > 0"
  980. assert data["name"] == "test_expr"
  981. restored = ExpressionCondition.from_dict(data)
  982. assert restored.expression == cond.expression
  983. class TestLambdaCondition:
  984. """Tests für LambdaCondition."""
  985. def test_simple_lambda(self):
  986. """Test einfache Lambda."""
  987. cond = LambdaCondition(lambda ctx: ctx.get("count", 0) >= 5)
  988. assert cond.evaluate({"count": 10})
  989. assert not cond.evaluate({"count": 2})
  990. def test_complex_lambda(self):
  991. """Test komplexe Lambda."""
  992. cond = LambdaCondition(
  993. lambda ctx: (
  994. ctx.get("a", 0) > 5 and
  995. ctx.get("b", "") == "test" and
  996. len(ctx.get("items", [])) > 0
  997. )
  998. )
  999. assert cond.evaluate({"a": 10, "b": "test", "items": [1, 2]})
  1000. assert not cond.evaluate({"a": 10, "b": "wrong", "items": [1]})
  1001. class TestExpressionHelpers:
  1002. """Tests für Expression Helper-Funktionen."""
  1003. def test_satellites_connected(self):
  1004. """Test satellites_connected Helper."""
  1005. cond = satellites_connected(min_count=2)
  1006. assert cond.expression == "satellites.count >= 2"
  1007. def test_plugin_enabled(self):
  1008. """Test plugin_enabled Helper."""
  1009. cond = plugin_enabled("spotify")
  1010. assert cond.expression == "plugins.is_enabled('spotify')"
  1011. # =============================================================================
  1012. # CronPresets Tests
  1013. # =============================================================================
  1014. class TestCronPresets:
  1015. """Tests für vordefinierte Cron-Ausdrücke."""
  1016. def test_presets_valid(self):
  1017. """Test alle Presets sind gültig."""
  1018. presets = [
  1019. CronPresets.EVERY_MINUTE,
  1020. CronPresets.EVERY_5_MINUTES,
  1021. CronPresets.EVERY_HOUR,
  1022. CronPresets.DAILY,
  1023. CronPresets.WEEKLY,
  1024. CronPresets.MONTHLY,
  1025. CronPresets.WEEKDAYS,
  1026. ]
  1027. for preset in presets:
  1028. trigger = CronTrigger(preset)
  1029. assert trigger is not None
  1030. if __name__ == "__main__":
  1031. pytest.main([__file__, "-v"])