test_satellite_extensions.py 39 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests für Satellite-Extensions.
  4. Testet:
  5. - Groups: SatelliteGroup, GroupManager
  6. - Zones: Zone, ZoneManager
  7. - LoadBalancing: Strategien und LoadBalancer
  8. - Health: HealthScorer, HealthMetrics, Thresholds
  9. - Failover: FailoverManager, BackupRegistry
  10. """
  11. import asyncio
  12. from datetime import datetime, timedelta
  13. from unittest.mock import MagicMock, AsyncMock
  14. import pytest
  15. # ============================================================================
  16. # Mock-Satellite für Tests
  17. # ============================================================================
  18. class MockSatellite:
  19. """Mock-Satellite für Tests."""
  20. def __init__(
  21. self,
  22. satellite_id: str,
  23. room_id: str = "default",
  24. is_connected: bool = True,
  25. ):
  26. self.id = satellite_id
  27. self.room_id = room_id
  28. self.is_connected = is_connected
  29. self.last_heartbeat = datetime.now()
  30. self.active_requests = 0
  31. self.response_time_ms = 50.0
  32. def __repr__(self) -> str:
  33. return f"MockSatellite(id={self.id!r})"
  34. # ============================================================================
  35. # Group Tests
  36. # ============================================================================
  37. class TestSatelliteGroup:
  38. """Tests für SatelliteGroup."""
  39. def test_create_group(self):
  40. """Test Gruppe erstellen."""
  41. from trixy_core.satellite.groups import SatelliteGroup, GroupType
  42. group = SatelliteGroup(
  43. group_id="test_group",
  44. name="Test Gruppe",
  45. group_type=GroupType.CUSTOM
  46. )
  47. assert group.id == "test_group"
  48. assert group.name == "Test Gruppe"
  49. assert group.group_type == GroupType.CUSTOM
  50. assert group.size == 0
  51. assert group.is_empty
  52. def test_add_satellite(self):
  53. """Test Satellite hinzufügen."""
  54. from trixy_core.satellite.groups import SatelliteGroup
  55. group = SatelliteGroup("test")
  56. sat = MockSatellite("sat1")
  57. assert group.add(sat) is True
  58. assert group.size == 1
  59. assert not group.is_empty
  60. assert group.contains("sat1")
  61. # Duplikat verhindern
  62. assert group.add(sat) is False
  63. assert group.size == 1
  64. def test_remove_satellite(self):
  65. """Test Satellite entfernen."""
  66. from trixy_core.satellite.groups import SatelliteGroup
  67. group = SatelliteGroup("test")
  68. sat = MockSatellite("sat1")
  69. group.add(sat)
  70. removed = group.remove("sat1")
  71. assert removed is sat
  72. assert group.size == 0
  73. # Nicht vorhanden
  74. assert group.remove("not_found") is None
  75. def test_max_members(self):
  76. """Test maximale Mitglieder."""
  77. from trixy_core.satellite.groups import SatelliteGroup, GroupConfig
  78. config = GroupConfig(max_members=2)
  79. group = SatelliteGroup("test", config=config)
  80. assert group.add(MockSatellite("sat1")) is True
  81. assert group.add(MockSatellite("sat2")) is True
  82. assert group.add(MockSatellite("sat3")) is False
  83. assert group.size == 2
  84. def test_filter_satellites(self):
  85. """Test Satellite-Filter."""
  86. from trixy_core.satellite.groups import SatelliteGroup
  87. group = SatelliteGroup("test")
  88. group.add(MockSatellite("sat1", is_connected=True))
  89. group.add(MockSatellite("sat2", is_connected=False))
  90. group.add(MockSatellite("sat3", is_connected=True))
  91. connected = group.filter(lambda s: s.is_connected)
  92. assert len(connected) == 2
  93. disconnected = group.filter(lambda s: not s.is_connected)
  94. assert len(disconnected) == 1
  95. def test_get_connected(self):
  96. """Test verbundene Satellites."""
  97. from trixy_core.satellite.groups import SatelliteGroup
  98. group = SatelliteGroup("test")
  99. group.add(MockSatellite("sat1", is_connected=True))
  100. group.add(MockSatellite("sat2", is_connected=False))
  101. connected = group.get_connected()
  102. assert len(connected) == 1
  103. assert connected[0].id == "sat1"
  104. def test_group_criteria(self):
  105. """Test automatische Zuordnung mit Kriterium."""
  106. from trixy_core.satellite.groups import SatelliteGroup
  107. group = SatelliteGroup(
  108. "room_test",
  109. criteria=lambda s: s.room_id == "wohnzimmer"
  110. )
  111. sat1 = MockSatellite("sat1", room_id="wohnzimmer")
  112. sat2 = MockSatellite("sat2", room_id="küche")
  113. assert group.matches(sat1) is True
  114. assert group.matches(sat2) is False
  115. def test_contains_operator(self):
  116. """Test __contains__ Operator."""
  117. from trixy_core.satellite.groups import SatelliteGroup
  118. group = SatelliteGroup("test")
  119. sat = MockSatellite("sat1")
  120. group.add(sat)
  121. assert "sat1" in group
  122. assert sat in group
  123. assert "sat2" not in group
  124. def test_iteration(self):
  125. """Test Iteration über Gruppe."""
  126. from trixy_core.satellite.groups import SatelliteGroup
  127. group = SatelliteGroup("test")
  128. group.add(MockSatellite("sat1"))
  129. group.add(MockSatellite("sat2"))
  130. ids = [s.id for s in group]
  131. assert "sat1" in ids
  132. assert "sat2" in ids
  133. def test_to_dict(self):
  134. """Test Serialisierung."""
  135. from trixy_core.satellite.groups import SatelliteGroup, GroupType
  136. group = SatelliteGroup("test", "Test Gruppe", GroupType.ROOM)
  137. group.add(MockSatellite("sat1"))
  138. data = group.to_dict()
  139. assert data["id"] == "test"
  140. assert data["name"] == "Test Gruppe"
  141. assert data["type"] == "ROOM"
  142. assert "sat1" in data["members"]
  143. class TestGroupManager:
  144. """Tests für GroupManager."""
  145. def test_create_group(self):
  146. """Test Gruppe erstellen."""
  147. from trixy_core.satellite.groups import GroupManager, GroupType
  148. manager = GroupManager()
  149. group = manager.create("test", "Test Gruppe", GroupType.CUSTOM)
  150. assert group.id == "test"
  151. assert manager.count == 1
  152. assert "test" in manager
  153. def test_create_duplicate_fails(self):
  154. """Test Duplikat-Erstellung verhindert."""
  155. from trixy_core.satellite.groups import GroupManager
  156. manager = GroupManager()
  157. manager.create("test")
  158. with pytest.raises(ValueError):
  159. manager.create("test")
  160. def test_create_room_group(self):
  161. """Test Raum-Gruppe erstellen."""
  162. from trixy_core.satellite.groups import GroupManager, GroupType
  163. manager = GroupManager()
  164. group = manager.create_room_group("wohnzimmer", "Wohnzimmer")
  165. assert group.group_type == GroupType.ROOM
  166. assert group.id == "room:wohnzimmer"
  167. # Über room_id abrufen
  168. assert manager.get_by_room("wohnzimmer") is group
  169. def test_create_zone_group(self):
  170. """Test Zone-Gruppe erstellen."""
  171. from trixy_core.satellite.groups import GroupManager, GroupType
  172. manager = GroupManager()
  173. group = manager.create_zone_group(
  174. "erdgeschoss",
  175. "Erdgeschoss",
  176. rooms=["wohnzimmer", "küche"]
  177. )
  178. assert group.group_type == GroupType.ZONE
  179. assert group.id == "zone:erdgeschoss"
  180. # Kriterium testen
  181. sat1 = MockSatellite("sat1", room_id="wohnzimmer")
  182. sat2 = MockSatellite("sat2", room_id="bad")
  183. assert group.matches(sat1) is True
  184. assert group.matches(sat2) is False
  185. def test_auto_assign(self):
  186. """Test automatische Zuordnung."""
  187. from trixy_core.satellite.groups import GroupManager
  188. manager = GroupManager()
  189. manager.create_room_group("wohnzimmer")
  190. manager.create_room_group("küche")
  191. sat = MockSatellite("sat1", room_id="wohnzimmer")
  192. assigned = manager.auto_assign(sat)
  193. assert "room:wohnzimmer" in assigned
  194. assert "room:küche" not in assigned
  195. def test_get_groups_for_satellite(self):
  196. """Test Gruppen für Satellite abrufen."""
  197. from trixy_core.satellite.groups import GroupManager
  198. manager = GroupManager()
  199. g1 = manager.create("group1")
  200. g2 = manager.create("group2")
  201. sat = MockSatellite("sat1")
  202. g1.add(sat)
  203. g2.add(sat)
  204. groups = manager.get_groups_for_satellite("sat1")
  205. assert len(groups) == 2
  206. def test_delete_group(self):
  207. """Test Gruppe löschen."""
  208. from trixy_core.satellite.groups import GroupManager
  209. manager = GroupManager()
  210. manager.create("test")
  211. deleted = manager.delete("test")
  212. assert deleted is not None
  213. assert "test" not in manager
  214. def test_find_groups(self):
  215. """Test Gruppen suchen."""
  216. from trixy_core.satellite.groups import GroupManager, GroupType
  217. manager = GroupManager()
  218. manager.create("g1", group_type=GroupType.ROOM)
  219. g2 = manager.create("g2", group_type=GroupType.ZONE)
  220. g2.add(MockSatellite("sat1", is_connected=True))
  221. # Nach Typ filtern
  222. rooms = manager.find_groups(group_type=GroupType.ROOM)
  223. assert len(rooms) == 1
  224. # Mit verbundenen Satellites
  225. with_connected = manager.find_groups(has_connected=True)
  226. assert len(with_connected) == 1
  227. # ============================================================================
  228. # Zone Tests
  229. # ============================================================================
  230. class TestZone:
  231. """Tests für Zone."""
  232. def test_create_zone(self):
  233. """Test Zone erstellen."""
  234. from trixy_core.satellite.groups import Zone
  235. zone = Zone(
  236. zone_id="erdgeschoss",
  237. name="Erdgeschoss",
  238. rooms=["wohnzimmer", "küche"]
  239. )
  240. assert zone.id == "erdgeschoss"
  241. assert zone.name == "Erdgeschoss"
  242. assert "wohnzimmer" in zone.rooms
  243. assert "küche" in zone.rooms
  244. def test_add_remove_room(self):
  245. """Test Raum hinzufügen/entfernen."""
  246. from trixy_core.satellite.groups import Zone
  247. zone = Zone("test")
  248. zone.add_room("flur")
  249. assert "flur" in zone.rooms
  250. zone.remove_room("flur")
  251. assert "flur" not in zone.rooms
  252. def test_zone_hierarchy(self):
  253. """Test Zone-Hierarchie."""
  254. from trixy_core.satellite.groups import Zone
  255. parent = Zone("haus", "Gesamtes Haus")
  256. child = Zone("erdgeschoss", "Erdgeschoss", rooms=["wohnzimmer"])
  257. parent.add_child(child)
  258. assert child.parent is parent
  259. assert child in parent.children
  260. assert child.depth == 1
  261. assert parent.depth == 0
  262. assert parent.is_root
  263. assert not child.is_root
  264. def test_all_rooms_recursive(self):
  265. """Test alle Räume rekursiv."""
  266. from trixy_core.satellite.groups import Zone
  267. parent = Zone("haus", rooms=["garage"])
  268. child1 = Zone("eg", rooms=["wohnzimmer", "küche"])
  269. child2 = Zone("og", rooms=["schlafzimmer", "bad"])
  270. parent.add_child(child1)
  271. parent.add_child(child2)
  272. all_rooms = parent.all_rooms
  273. assert "garage" in all_rooms
  274. assert "wohnzimmer" in all_rooms
  275. assert "schlafzimmer" in all_rooms
  276. assert len(all_rooms) == 5
  277. def test_contains_room_recursive(self):
  278. """Test contains_room rekursiv."""
  279. from trixy_core.satellite.groups import Zone
  280. parent = Zone("haus")
  281. child = Zone("eg", rooms=["wohnzimmer"])
  282. parent.add_child(child)
  283. assert parent.contains_room("wohnzimmer", recursive=True)
  284. assert not parent.contains_room("wohnzimmer", recursive=False)
  285. assert "wohnzimmer" in parent # __contains__ ist rekursiv
  286. def test_get_path(self):
  287. """Test Pfad zu Zone."""
  288. from trixy_core.satellite.groups import Zone
  289. root = Zone("haus")
  290. level1 = Zone("eg")
  291. level2 = Zone("wohnbereich")
  292. root.add_child(level1)
  293. level1.add_child(level2)
  294. path = level2.get_path()
  295. assert len(path) == 3
  296. assert path[0] is root
  297. assert path[2] is level2
  298. class TestZoneManager:
  299. """Tests für ZoneManager."""
  300. def test_create_zone(self):
  301. """Test Zone erstellen."""
  302. from trixy_core.satellite.groups import ZoneManager
  303. manager = ZoneManager()
  304. zone = manager.create("test", "Test Zone", rooms=["raum1"])
  305. assert zone.id == "test"
  306. assert manager.count == 1
  307. assert "test" in manager
  308. def test_create_with_parent(self):
  309. """Test Zone mit Parent erstellen."""
  310. from trixy_core.satellite.groups import ZoneManager
  311. manager = ZoneManager()
  312. parent = manager.create("parent")
  313. child = manager.create("child", parent="parent")
  314. assert child.parent is parent
  315. assert child in parent.children
  316. def test_invalid_parent_raises(self):
  317. """Test ungültiger Parent wirft Fehler."""
  318. from trixy_core.satellite.groups import ZoneManager
  319. manager = ZoneManager()
  320. with pytest.raises(ValueError):
  321. manager.create("child", parent="nonexistent")
  322. def test_get_zone_for_room(self):
  323. """Test Zone für Raum finden."""
  324. from trixy_core.satellite.groups import ZoneManager
  325. manager = ZoneManager()
  326. zone = manager.create("eg", rooms=["wohnzimmer", "küche"])
  327. found = manager.get_zone_for_room("wohnzimmer")
  328. assert found is zone
  329. not_found = manager.get_zone_for_room("garage")
  330. assert not_found is None
  331. def test_move_room(self):
  332. """Test Raum verschieben."""
  333. from trixy_core.satellite.groups import ZoneManager
  334. manager = ZoneManager()
  335. z1 = manager.create("z1", rooms=["raum1"])
  336. z2 = manager.create("z2")
  337. assert manager.move_room("raum1", "z2") is True
  338. assert "raum1" not in z1.rooms
  339. assert "raum1" in z2.rooms
  340. def test_delete_zone(self):
  341. """Test Zone löschen."""
  342. from trixy_core.satellite.groups import ZoneManager
  343. manager = ZoneManager()
  344. manager.create("test")
  345. deleted = manager.delete("test")
  346. assert deleted is not None
  347. assert "test" not in manager
  348. def test_delete_recursive(self):
  349. """Test rekursives Löschen."""
  350. from trixy_core.satellite.groups import ZoneManager
  351. manager = ZoneManager()
  352. manager.create("parent")
  353. manager.create("child1", parent="parent")
  354. manager.create("child2", parent="parent")
  355. manager.delete("parent", recursive=True)
  356. assert manager.count == 0
  357. def test_get_hierarchy(self):
  358. """Test Hierarchie abrufen."""
  359. from trixy_core.satellite.groups import ZoneManager
  360. manager = ZoneManager()
  361. manager.create("root1", rooms=["r1"])
  362. manager.create("child", parent="root1", rooms=["r2"])
  363. manager.create("root2", rooms=["r3"])
  364. hierarchy = manager.get_hierarchy()
  365. assert len(hierarchy["root_zones"]) == 2
  366. # ============================================================================
  367. # LoadBalancing Tests
  368. # ============================================================================
  369. class TestLoadBalancingStrategy:
  370. """Tests für Load-Balancing-Strategien."""
  371. def test_round_robin(self):
  372. """Test Round-Robin-Strategie."""
  373. from trixy_core.satellite.loadbalancing import RoundRobinStrategy
  374. strategy = RoundRobinStrategy()
  375. satellites = [
  376. MockSatellite("sat1"),
  377. MockSatellite("sat2"),
  378. MockSatellite("sat3"),
  379. ]
  380. # Sollte in Reihenfolge durchgehen
  381. results = []
  382. for _ in range(6):
  383. result = strategy.select(satellites)
  384. results.append(result.satellite.id)
  385. # Jeder Satellite sollte 2x vorkommen
  386. assert results.count("sat1") == 2
  387. assert results.count("sat2") == 2
  388. assert results.count("sat3") == 2
  389. def test_round_robin_handles_removed_satellite(self):
  390. """Test Round-Robin bei entferntem Satellite."""
  391. from trixy_core.satellite.loadbalancing import RoundRobinStrategy
  392. strategy = RoundRobinStrategy()
  393. satellites = [MockSatellite("sat1"), MockSatellite("sat2")]
  394. strategy.select(satellites) # sat1
  395. # Satellite entfernen
  396. satellites = [MockSatellite("sat2")]
  397. result = strategy.select(satellites)
  398. assert result.satellite.id == "sat2"
  399. def test_round_robin_empty_list(self):
  400. """Test Round-Robin mit leerer Liste."""
  401. from trixy_core.satellite.loadbalancing import RoundRobinStrategy
  402. strategy = RoundRobinStrategy()
  403. result = strategy.select([])
  404. assert result is None
  405. def test_least_connections(self):
  406. """Test Least-Connections-Strategie."""
  407. from trixy_core.satellite.loadbalancing import LeastConnectionsStrategy
  408. strategy = LeastConnectionsStrategy()
  409. satellites = [
  410. MockSatellite("sat1"),
  411. MockSatellite("sat2"),
  412. MockSatellite("sat3"),
  413. ]
  414. # Initial alle gleich, erstes ausgewählt
  415. result1 = strategy.select(satellites)
  416. strategy.on_request_start(result1.satellite)
  417. # Jetzt sollte sat2 oder sat3 gewählt werden (weniger Connections)
  418. result2 = strategy.select(satellites)
  419. assert result2.satellite.id != "sat1"
  420. def test_least_connections_end_request(self):
  421. """Test Least-Connections Connection-Tracking."""
  422. from trixy_core.satellite.loadbalancing import LeastConnectionsStrategy
  423. strategy = LeastConnectionsStrategy()
  424. sat = MockSatellite("sat1")
  425. strategy.on_request_start(sat)
  426. assert strategy.get_connection_count("sat1") == 1
  427. strategy.on_request_end(sat)
  428. assert strategy.get_connection_count("sat1") == 0
  429. def test_weighted_strategy(self):
  430. """Test Weighted-Strategie."""
  431. from trixy_core.satellite.loadbalancing import WeightedStrategy, WeightConfig
  432. strategy = WeightedStrategy()
  433. # Hohe Gewichtung für sat1
  434. strategy.set_weight("sat1", WeightConfig(weight=10.0))
  435. strategy.set_weight("sat2", WeightConfig(weight=1.0))
  436. satellites = [MockSatellite("sat1"), MockSatellite("sat2")]
  437. # sat1 sollte deutlich öfter gewählt werden
  438. counts = {"sat1": 0, "sat2": 0}
  439. for _ in range(100):
  440. result = strategy.select(satellites)
  441. counts[result.satellite.id] += 1
  442. # sat1 sollte ca. 90% bekommen
  443. assert counts["sat1"] > counts["sat2"] * 5
  444. def test_weighted_zero_weight(self):
  445. """Test Weighted mit Gewicht 0."""
  446. from trixy_core.satellite.loadbalancing import WeightedStrategy, WeightConfig
  447. strategy = WeightedStrategy()
  448. strategy.set_weight("sat1", WeightConfig(weight=0.0))
  449. strategy.set_weight("sat2", WeightConfig(weight=1.0))
  450. satellites = [MockSatellite("sat1"), MockSatellite("sat2")]
  451. # Nur sat2 sollte gewählt werden
  452. for _ in range(10):
  453. result = strategy.select(satellites)
  454. assert result.satellite.id == "sat2"
  455. class TestLoadBalancer:
  456. """Tests für LoadBalancer."""
  457. def test_load_balancer_select(self):
  458. """Test LoadBalancer Auswahl."""
  459. from trixy_core.satellite.loadbalancing import (
  460. LoadBalancer,
  461. RoundRobinStrategy
  462. )
  463. balancer = LoadBalancer(RoundRobinStrategy())
  464. satellites = [MockSatellite("sat1"), MockSatellite("sat2")]
  465. result = balancer.select(satellites)
  466. assert result is not None
  467. assert result.satellite.id in ["sat1", "sat2"]
  468. def test_load_balancer_filters_disconnected(self):
  469. """Test LoadBalancer filtert nicht verbundene automatisch."""
  470. from trixy_core.satellite.loadbalancing import (
  471. LoadBalancer,
  472. RoundRobinStrategy
  473. )
  474. balancer = LoadBalancer(RoundRobinStrategy())
  475. satellites = [
  476. MockSatellite("sat1", is_connected=False),
  477. MockSatellite("sat2", is_connected=True),
  478. ]
  479. # LoadBalancer filtert disconnected automatisch
  480. result = balancer.select(satellites)
  481. assert result.satellite.id == "sat2"
  482. def test_load_balancer_change_strategy(self):
  483. """Test Strategie-Wechsel."""
  484. from trixy_core.satellite.loadbalancing import (
  485. LoadBalancer,
  486. RoundRobinStrategy,
  487. LeastConnectionsStrategy
  488. )
  489. balancer = LoadBalancer(RoundRobinStrategy())
  490. assert balancer.strategy.name == "round_robin"
  491. # Strategie über Property-Setter ändern
  492. balancer.strategy = LeastConnectionsStrategy()
  493. assert balancer.strategy.name == "least_connections"
  494. # ============================================================================
  495. # Health Tests
  496. # ============================================================================
  497. class TestHealthMetrics:
  498. """Tests für HealthMetrics."""
  499. def test_create_metrics(self):
  500. """Test Metriken erstellen."""
  501. from trixy_core.satellite.health import HealthMetrics
  502. metrics = HealthMetrics(
  503. latency_ms=50.0,
  504. packet_loss=0.01,
  505. uptime=0.99,
  506. )
  507. assert metrics.latency_ms == 50.0
  508. assert metrics.packet_loss == 0.01
  509. assert metrics.uptime == 0.99
  510. def test_custom_metrics(self):
  511. """Test benutzerdefinierte Metriken."""
  512. from trixy_core.satellite.health import HealthMetrics
  513. metrics = HealthMetrics()
  514. metrics.custom_metrics["audio_quality"] = 0.95
  515. assert metrics.custom_metrics["audio_quality"] == 0.95
  516. class TestHealthScorer:
  517. """Tests für HealthScorer."""
  518. def test_calculate_score(self):
  519. """Test Score-Berechnung."""
  520. from trixy_core.satellite.health import HealthScorer, HealthMetrics
  521. scorer = HealthScorer()
  522. metrics = HealthMetrics(
  523. latency_ms=50.0,
  524. packet_loss=0.0,
  525. uptime=1.0,
  526. error_rate=0.0,
  527. )
  528. # Metriken zuerst speichern
  529. scorer.update_metrics("sat1", metrics)
  530. score = scorer.calculate_score("sat1")
  531. # Bei perfekten Werten sollte Score hoch sein
  532. assert score.overall > 80
  533. def test_health_state_mapping(self):
  534. """Test Health-State-Zuordnung."""
  535. from trixy_core.satellite.health import (
  536. HealthScorer,
  537. HealthMetrics,
  538. HealthState
  539. )
  540. scorer = HealthScorer()
  541. # Gute Metriken -> HEALTHY
  542. good = HealthMetrics(latency_ms=20, packet_loss=0, uptime=1.0)
  543. scorer.update_metrics("sat_good", good)
  544. score = scorer.calculate_score("sat_good")
  545. assert score.state == HealthState.HEALTHY
  546. # Mittelmäßige Metriken -> DEGRADED
  547. degraded = HealthMetrics(latency_ms=500, packet_loss=0.2, uptime=0.5)
  548. scorer.update_metrics("sat_degraded", degraded)
  549. score = scorer.calculate_score("sat_degraded")
  550. assert score.state in [HealthState.DEGRADED, HealthState.UNHEALTHY]
  551. # Sehr schlechte Metriken -> CRITICAL oder UNHEALTHY
  552. critical = HealthMetrics(
  553. latency_ms=1000,
  554. packet_loss=0.5,
  555. uptime=0.3,
  556. error_rate=0.5,
  557. last_heartbeat_age=120.0,
  558. )
  559. scorer.update_metrics("sat_critical", critical)
  560. score = scorer.calculate_score("sat_critical")
  561. assert score.state in [HealthState.CRITICAL, HealthState.UNHEALTHY]
  562. def test_custom_weights(self):
  563. """Test benutzerdefinierte Gewichtung."""
  564. from trixy_core.satellite.health import HealthScorer, HealthMetrics
  565. scorer = HealthScorer(weights={
  566. "latency": 0.8,
  567. "packet_loss": 0.1,
  568. "uptime": 0.1,
  569. })
  570. metrics = HealthMetrics(latency_ms=200, packet_loss=0.0, uptime=1.0)
  571. scorer.update_metrics("sat1", metrics)
  572. score = scorer.calculate_score("sat1")
  573. # Mit hoher Latenz-Gewichtung sollte Score niedriger sein
  574. assert score.overall < 80
  575. def test_score_history(self):
  576. """Test Score-Historie."""
  577. from trixy_core.satellite.health import HealthScorer, HealthMetrics
  578. scorer = HealthScorer(history_size=5)
  579. for i in range(3):
  580. metrics = HealthMetrics(latency_ms=50 + i * 10)
  581. scorer.update_metrics("sat1", metrics)
  582. scorer.calculate_score("sat1")
  583. history = scorer.get_history("sat1")
  584. assert len(history) == 3
  585. class TestHealthThresholds:
  586. """Tests für HealthThresholds."""
  587. def test_default_thresholds(self):
  588. """Test Standard-Schwellenwerte."""
  589. from trixy_core.satellite.health import HealthThresholds
  590. thresholds = HealthThresholds()
  591. # Sollte Default-Configs haben
  592. assert thresholds.get("latency_ms") is not None
  593. assert thresholds.get("packet_loss") is not None
  594. def test_add_threshold(self):
  595. """Test Schwellenwert hinzufügen."""
  596. from trixy_core.satellite.health import (
  597. HealthThresholds,
  598. ThresholdConfig
  599. )
  600. thresholds = HealthThresholds(use_defaults=False)
  601. config = ThresholdConfig(
  602. metric_name="custom_metric",
  603. warning_threshold=50.0,
  604. error_threshold=80.0,
  605. )
  606. thresholds.add(config)
  607. assert thresholds.get("custom_metric") is config
  608. def test_check_violations(self):
  609. """Test Schwellenwert-Verletzung erkennen."""
  610. from trixy_core.satellite.health import (
  611. HealthThresholds,
  612. HealthMetrics,
  613. ThresholdConfig,
  614. ViolationSeverity
  615. )
  616. thresholds = HealthThresholds(use_defaults=False)
  617. thresholds.add(ThresholdConfig(
  618. metric_name="latency_ms",
  619. warning_threshold=100.0,
  620. error_threshold=200.0,
  621. critical_threshold=500.0,
  622. ))
  623. # Über Warning-Schwelle
  624. metrics = HealthMetrics(latency_ms=150.0)
  625. violations = thresholds.check("sat1", metrics)
  626. assert len(violations) == 1
  627. assert violations[0].severity == ViolationSeverity.WARNING
  628. def test_violation_callback(self):
  629. """Test Callback bei Verletzung."""
  630. from trixy_core.satellite.health import (
  631. HealthThresholds,
  632. HealthMetrics,
  633. ThresholdConfig
  634. )
  635. thresholds = HealthThresholds(use_defaults=False)
  636. thresholds.add(ThresholdConfig(
  637. metric_name="latency_ms",
  638. warning_threshold=100.0,
  639. ))
  640. violations_received = []
  641. @thresholds.on_violation
  642. def handler(violation):
  643. violations_received.append(violation)
  644. metrics = HealthMetrics(latency_ms=150.0)
  645. thresholds.check("sat1", metrics)
  646. assert len(violations_received) == 1
  647. def test_cooldown(self):
  648. """Test Cooldown zwischen Alarmen."""
  649. from trixy_core.satellite.health import (
  650. HealthThresholds,
  651. HealthMetrics,
  652. ThresholdConfig
  653. )
  654. thresholds = HealthThresholds(use_defaults=False)
  655. thresholds.add(ThresholdConfig(
  656. metric_name="latency_ms",
  657. warning_threshold=100.0,
  658. cooldown=300.0, # 5 Minuten
  659. ))
  660. metrics = HealthMetrics(latency_ms=150.0)
  661. # Erste Prüfung: Verletzung
  662. v1 = thresholds.check("sat1", metrics)
  663. assert len(v1) == 1
  664. # Zweite Prüfung: Cooldown aktiv, keine Verletzung
  665. v2 = thresholds.check("sat1", metrics)
  666. assert len(v2) == 0
  667. # Cooldown zurücksetzen
  668. thresholds.clear_cooldowns("sat1")
  669. v3 = thresholds.check("sat1", metrics)
  670. assert len(v3) == 1
  671. # ============================================================================
  672. # Failover Tests
  673. # ============================================================================
  674. class TestBackupRegistry:
  675. """Tests für BackupRegistry."""
  676. def test_assign_backup(self):
  677. """Test Backup zuweisen."""
  678. from trixy_core.satellite.failover import BackupRegistry
  679. registry = BackupRegistry()
  680. assignment = registry.assign("primary1", "backup1", priority=10)
  681. assert assignment.primary_id == "primary1"
  682. assert assignment.backup_id == "backup1"
  683. assert assignment.priority == 10
  684. def test_assign_self_fails(self):
  685. """Test Self-Assignment verhindert."""
  686. from trixy_core.satellite.failover import BackupRegistry
  687. registry = BackupRegistry()
  688. with pytest.raises(ValueError):
  689. registry.assign("sat1", "sat1")
  690. def test_multiple_backups_priority(self):
  691. """Test mehrere Backups nach Priorität."""
  692. from trixy_core.satellite.failover import BackupRegistry
  693. registry = BackupRegistry()
  694. registry.assign("primary", "backup1", priority=5)
  695. registry.assign("primary", "backup2", priority=10)
  696. registry.assign("primary", "backup3", priority=1)
  697. backups = registry.get_backups("primary")
  698. # Sollte nach Priorität sortiert sein
  699. assert backups[0].backup_id == "backup2" # priority=10
  700. assert backups[1].backup_id == "backup1" # priority=5
  701. assert backups[2].backup_id == "backup3" # priority=1
  702. def test_get_best_backup(self):
  703. """Test bestes Backup abrufen."""
  704. from trixy_core.satellite.failover import BackupRegistry
  705. def get_satellite(sat_id):
  706. if sat_id == "backup1":
  707. return MockSatellite(sat_id, is_connected=False)
  708. return MockSatellite(sat_id, is_connected=True)
  709. registry = BackupRegistry(get_satellite=get_satellite)
  710. registry.assign("primary", "backup1", priority=10) # disconnected
  711. registry.assign("primary", "backup2", priority=5) # connected
  712. best = registry.get_best_backup("primary")
  713. # backup1 hat höhere Priorität aber ist disconnected
  714. assert best.backup_id == "backup2"
  715. def test_unassign_backup(self):
  716. """Test Backup-Zuweisung entfernen."""
  717. from trixy_core.satellite.failover import BackupRegistry
  718. registry = BackupRegistry()
  719. registry.assign("primary", "backup1")
  720. registry.assign("primary", "backup2")
  721. removed = registry.unassign("primary", "backup1")
  722. assert removed == 1
  723. backups = registry.get_backups("primary")
  724. assert len(backups) == 1
  725. def test_unassign_all(self):
  726. """Test alle Backups entfernen."""
  727. from trixy_core.satellite.failover import BackupRegistry
  728. registry = BackupRegistry()
  729. registry.assign("primary", "backup1")
  730. registry.assign("primary", "backup2")
  731. removed = registry.unassign("primary")
  732. assert removed == 2
  733. assert len(registry.get_backups("primary")) == 0
  734. def test_backup_strategies(self):
  735. """Test Backup-Strategien."""
  736. from trixy_core.satellite.failover import (
  737. BackupRegistry,
  738. BackupStrategy
  739. )
  740. def get_all():
  741. return [
  742. MockSatellite("sat1", room_id="room1"),
  743. MockSatellite("sat2", room_id="room1"),
  744. MockSatellite("sat3", room_id="room2"),
  745. ]
  746. def get_sat(sat_id):
  747. sats = {s.id: s for s in get_all()}
  748. return sats.get(sat_id)
  749. registry = BackupRegistry(
  750. default_strategy=BackupStrategy.SAME_ROOM,
  751. get_satellite=get_sat,
  752. get_all_satellites=get_all,
  753. )
  754. # sat1 sucht Backup in room1
  755. backup = registry.get_best_backup("sat1")
  756. assert backup is not None
  757. assert backup.backup_id == "sat2" # Im selben Raum
  758. def test_update_health_score(self):
  759. """Test Health-Score für Backup-Auswahl."""
  760. from trixy_core.satellite.failover import (
  761. BackupRegistry,
  762. BackupStrategy
  763. )
  764. satellites = [
  765. MockSatellite("sat1"),
  766. MockSatellite("sat2"),
  767. MockSatellite("sat3"),
  768. ]
  769. registry = BackupRegistry(
  770. default_strategy=BackupStrategy.HEALTH_BASED,
  771. get_satellite=lambda id: next((s for s in satellites if s.id == id), None),
  772. get_all_satellites=lambda: satellites,
  773. )
  774. registry.update_health_score("sat2", 90.0)
  775. registry.update_health_score("sat3", 50.0)
  776. backup = registry.get_best_backup("sat1")
  777. assert backup.backup_id == "sat2" # Höherer Health-Score
  778. class TestFailoverManager:
  779. """Tests für FailoverManager."""
  780. @pytest.fixture
  781. def setup_manager(self):
  782. """Fixture für FailoverManager Setup."""
  783. from trixy_core.satellite.failover import (
  784. FailoverManager,
  785. FailoverConfig,
  786. BackupRegistry
  787. )
  788. satellites = {
  789. "primary1": MockSatellite("primary1"),
  790. "backup1": MockSatellite("backup1"),
  791. "backup2": MockSatellite("backup2"),
  792. }
  793. registry = BackupRegistry(
  794. get_satellite=lambda id: satellites.get(id),
  795. get_all_satellites=lambda: list(satellites.values()),
  796. )
  797. registry.assign("primary1", "backup1", priority=10)
  798. registry.assign("primary1", "backup2", priority=5)
  799. manager = FailoverManager(
  800. backup_registry=registry,
  801. config=FailoverConfig(detection_timeout=5.0),
  802. get_satellite=lambda id: satellites.get(id),
  803. )
  804. return manager, satellites
  805. @pytest.mark.asyncio
  806. async def test_trigger_failover(self, setup_manager):
  807. """Test Failover auslösen."""
  808. from trixy_core.satellite.failover import FailoverState
  809. manager, _ = setup_manager
  810. event = await manager.trigger_failover(
  811. "primary1",
  812. reason="Test-Failover"
  813. )
  814. assert event is not None
  815. assert event.primary_id == "primary1"
  816. assert event.backup_id == "backup1" # Höchste Priorität
  817. assert event.state == FailoverState.ACTIVE
  818. @pytest.mark.asyncio
  819. async def test_failover_no_backup_available(self, setup_manager):
  820. """Test Failover ohne verfügbares Backup."""
  821. from trixy_core.satellite.failover import FailoverState
  822. manager, satellites = setup_manager
  823. # Beide Backups disconnecten
  824. satellites["backup1"].is_connected = False
  825. satellites["backup2"].is_connected = False
  826. event = await manager.trigger_failover("primary1")
  827. assert event.state == FailoverState.FAILED
  828. assert "Kein Backup" in event.reason
  829. @pytest.mark.asyncio
  830. async def test_failover_callback(self, setup_manager):
  831. """Test Failover-Callback."""
  832. manager, _ = setup_manager
  833. events_received = []
  834. @manager.on_failover
  835. async def handler(event):
  836. events_received.append(event)
  837. await manager.trigger_failover("primary1")
  838. assert len(events_received) == 1
  839. @pytest.mark.asyncio
  840. async def test_recover(self, setup_manager):
  841. """Test Recovery nach Failover."""
  842. from trixy_core.satellite.failover import FailoverState
  843. manager, satellites = setup_manager
  844. # Failover auslösen
  845. await manager.trigger_failover("primary1")
  846. assert "primary1" in manager.active_failovers
  847. # Recovery
  848. success = await manager.recover("primary1")
  849. assert success is True
  850. assert "primary1" not in manager.active_failovers
  851. @pytest.mark.asyncio
  852. async def test_prevent_duplicate_failover(self, setup_manager):
  853. """Test verhindert doppeltes Failover."""
  854. manager, _ = setup_manager
  855. event1 = await manager.trigger_failover("primary1")
  856. event2 = await manager.trigger_failover("primary1")
  857. # Sollte das gleiche Event zurückgeben
  858. assert event1 is event2
  859. @pytest.mark.asyncio
  860. async def test_get_history(self, setup_manager):
  861. """Test Failover-Historie."""
  862. manager, _ = setup_manager
  863. await manager.trigger_failover("primary1", reason="Test 1")
  864. await manager.recover("primary1")
  865. history = manager.get_history()
  866. assert len(history) >= 1
  867. def test_get_statistics(self, setup_manager):
  868. """Test Statistiken."""
  869. manager, _ = setup_manager
  870. stats = manager.get_statistics()
  871. assert "total_failovers" in stats
  872. assert "successful" in stats
  873. assert "failed" in stats
  874. assert "success_rate" in stats
  875. # ============================================================================
  876. # Integration Tests
  877. # ============================================================================
  878. class TestSatelliteExtensionsIntegration:
  879. """Integration-Tests für alle Satellite-Extensions."""
  880. def test_groups_with_loadbalancing(self):
  881. """Test Gruppen mit Load-Balancing."""
  882. from trixy_core.satellite.groups import SatelliteGroup
  883. from trixy_core.satellite.loadbalancing import (
  884. LoadBalancer,
  885. RoundRobinStrategy
  886. )
  887. group = SatelliteGroup("test")
  888. group.add(MockSatellite("sat1"))
  889. group.add(MockSatellite("sat2"))
  890. group.add(MockSatellite("sat3"))
  891. balancer = LoadBalancer(RoundRobinStrategy())
  892. # Load-Balancing über Gruppe
  893. result = balancer.select(group.get_connected())
  894. assert result is not None
  895. def test_health_triggers_failover(self):
  896. """Test Health löst Failover aus."""
  897. from trixy_core.satellite.health import (
  898. HealthScorer,
  899. HealthMetrics,
  900. HealthState
  901. )
  902. from trixy_core.satellite.failover import (
  903. FailoverManager,
  904. FailoverConfig,
  905. BackupRegistry
  906. )
  907. # Health Score berechnen
  908. scorer = HealthScorer()
  909. bad_metrics = HealthMetrics(
  910. latency_ms=600,
  911. packet_loss=0.15,
  912. error_rate=0.1,
  913. )
  914. scorer.update_metrics("sat1", bad_metrics)
  915. score = scorer.calculate_score("sat1")
  916. # Bei kritischem Zustand sollte Failover möglich sein
  917. if score.state in [HealthState.CRITICAL, HealthState.UNHEALTHY]:
  918. registry = BackupRegistry()
  919. manager = FailoverManager(
  920. backup_registry=registry,
  921. config=FailoverConfig(health_threshold=40.0),
  922. )
  923. # Überprüfung: Score liegt unter Threshold?
  924. assert score.overall < manager.config.health_threshold or score.state != HealthState.HEALTHY
  925. def test_zone_hierarchy_with_groups(self):
  926. """Test Zone-Hierarchie mit Gruppen."""
  927. from trixy_core.satellite.groups import (
  928. Zone,
  929. ZoneManager,
  930. GroupManager
  931. )
  932. # Zonen erstellen
  933. zones = ZoneManager()
  934. zones.create("haus", "Gesamtes Haus")
  935. zones.create("eg", "Erdgeschoss", rooms=["wohnzimmer", "küche"], parent="haus")
  936. zones.create("og", "Obergeschoss", rooms=["schlafzimmer"], parent="haus")
  937. # Gruppen für Räume
  938. groups = GroupManager()
  939. groups.create_room_group("wohnzimmer")
  940. groups.create_room_group("küche")
  941. # Satellites zuweisen
  942. sat1 = MockSatellite("sat1", room_id="wohnzimmer")
  943. sat2 = MockSatellite("sat2", room_id="küche")
  944. groups.auto_assign(sat1)
  945. groups.auto_assign(sat2)
  946. # Zone sollte beide Räume enthalten
  947. eg = zones.get("eg")
  948. assert "wohnzimmer" in eg
  949. assert "küche" in eg
  950. if __name__ == "__main__":
  951. pytest.main([__file__, "-v"])