# -*- coding: utf-8 -*- """ Tests für Satellite-Extensions. Testet: - Groups: SatelliteGroup, GroupManager - Zones: Zone, ZoneManager - LoadBalancing: Strategien und LoadBalancer - Health: HealthScorer, HealthMetrics, Thresholds - Failover: FailoverManager, BackupRegistry """ import asyncio from datetime import datetime, timedelta from unittest.mock import MagicMock, AsyncMock import pytest # ============================================================================ # Mock-Satellite für Tests # ============================================================================ class MockSatellite: """Mock-Satellite für Tests.""" def __init__( self, satellite_id: str, room_id: str = "default", is_connected: bool = True, ): self.id = satellite_id self.room_id = room_id self.is_connected = is_connected self.last_heartbeat = datetime.now() self.active_requests = 0 self.response_time_ms = 50.0 def __repr__(self) -> str: return f"MockSatellite(id={self.id!r})" # ============================================================================ # Group Tests # ============================================================================ class TestSatelliteGroup: """Tests für SatelliteGroup.""" def test_create_group(self): """Test Gruppe erstellen.""" from trixy_core.satellite.groups import SatelliteGroup, GroupType group = SatelliteGroup( group_id="test_group", name="Test Gruppe", group_type=GroupType.CUSTOM ) assert group.id == "test_group" assert group.name == "Test Gruppe" assert group.group_type == GroupType.CUSTOM assert group.size == 0 assert group.is_empty def test_add_satellite(self): """Test Satellite hinzufügen.""" from trixy_core.satellite.groups import SatelliteGroup group = SatelliteGroup("test") sat = MockSatellite("sat1") assert group.add(sat) is True assert group.size == 1 assert not group.is_empty assert group.contains("sat1") # Duplikat verhindern assert group.add(sat) is False assert group.size == 1 def test_remove_satellite(self): """Test Satellite entfernen.""" from trixy_core.satellite.groups import SatelliteGroup group = SatelliteGroup("test") sat = MockSatellite("sat1") group.add(sat) removed = group.remove("sat1") assert removed is sat assert group.size == 0 # Nicht vorhanden assert group.remove("not_found") is None def test_max_members(self): """Test maximale Mitglieder.""" from trixy_core.satellite.groups import SatelliteGroup, GroupConfig config = GroupConfig(max_members=2) group = SatelliteGroup("test", config=config) assert group.add(MockSatellite("sat1")) is True assert group.add(MockSatellite("sat2")) is True assert group.add(MockSatellite("sat3")) is False assert group.size == 2 def test_filter_satellites(self): """Test Satellite-Filter.""" from trixy_core.satellite.groups import SatelliteGroup group = SatelliteGroup("test") group.add(MockSatellite("sat1", is_connected=True)) group.add(MockSatellite("sat2", is_connected=False)) group.add(MockSatellite("sat3", is_connected=True)) connected = group.filter(lambda s: s.is_connected) assert len(connected) == 2 disconnected = group.filter(lambda s: not s.is_connected) assert len(disconnected) == 1 def test_get_connected(self): """Test verbundene Satellites.""" from trixy_core.satellite.groups import SatelliteGroup group = SatelliteGroup("test") group.add(MockSatellite("sat1", is_connected=True)) group.add(MockSatellite("sat2", is_connected=False)) connected = group.get_connected() assert len(connected) == 1 assert connected[0].id == "sat1" def test_group_criteria(self): """Test automatische Zuordnung mit Kriterium.""" from trixy_core.satellite.groups import SatelliteGroup group = SatelliteGroup( "room_test", criteria=lambda s: s.room_id == "wohnzimmer" ) sat1 = MockSatellite("sat1", room_id="wohnzimmer") sat2 = MockSatellite("sat2", room_id="küche") assert group.matches(sat1) is True assert group.matches(sat2) is False def test_contains_operator(self): """Test __contains__ Operator.""" from trixy_core.satellite.groups import SatelliteGroup group = SatelliteGroup("test") sat = MockSatellite("sat1") group.add(sat) assert "sat1" in group assert sat in group assert "sat2" not in group def test_iteration(self): """Test Iteration über Gruppe.""" from trixy_core.satellite.groups import SatelliteGroup group = SatelliteGroup("test") group.add(MockSatellite("sat1")) group.add(MockSatellite("sat2")) ids = [s.id for s in group] assert "sat1" in ids assert "sat2" in ids def test_to_dict(self): """Test Serialisierung.""" from trixy_core.satellite.groups import SatelliteGroup, GroupType group = SatelliteGroup("test", "Test Gruppe", GroupType.ROOM) group.add(MockSatellite("sat1")) data = group.to_dict() assert data["id"] == "test" assert data["name"] == "Test Gruppe" assert data["type"] == "ROOM" assert "sat1" in data["members"] class TestGroupManager: """Tests für GroupManager.""" def test_create_group(self): """Test Gruppe erstellen.""" from trixy_core.satellite.groups import GroupManager, GroupType manager = GroupManager() group = manager.create("test", "Test Gruppe", GroupType.CUSTOM) assert group.id == "test" assert manager.count == 1 assert "test" in manager def test_create_duplicate_fails(self): """Test Duplikat-Erstellung verhindert.""" from trixy_core.satellite.groups import GroupManager manager = GroupManager() manager.create("test") with pytest.raises(ValueError): manager.create("test") def test_create_room_group(self): """Test Raum-Gruppe erstellen.""" from trixy_core.satellite.groups import GroupManager, GroupType manager = GroupManager() group = manager.create_room_group("wohnzimmer", "Wohnzimmer") assert group.group_type == GroupType.ROOM assert group.id == "room:wohnzimmer" # Über room_id abrufen assert manager.get_by_room("wohnzimmer") is group def test_create_zone_group(self): """Test Zone-Gruppe erstellen.""" from trixy_core.satellite.groups import GroupManager, GroupType manager = GroupManager() group = manager.create_zone_group( "erdgeschoss", "Erdgeschoss", rooms=["wohnzimmer", "küche"] ) assert group.group_type == GroupType.ZONE assert group.id == "zone:erdgeschoss" # Kriterium testen sat1 = MockSatellite("sat1", room_id="wohnzimmer") sat2 = MockSatellite("sat2", room_id="bad") assert group.matches(sat1) is True assert group.matches(sat2) is False def test_auto_assign(self): """Test automatische Zuordnung.""" from trixy_core.satellite.groups import GroupManager manager = GroupManager() manager.create_room_group("wohnzimmer") manager.create_room_group("küche") sat = MockSatellite("sat1", room_id="wohnzimmer") assigned = manager.auto_assign(sat) assert "room:wohnzimmer" in assigned assert "room:küche" not in assigned def test_get_groups_for_satellite(self): """Test Gruppen für Satellite abrufen.""" from trixy_core.satellite.groups import GroupManager manager = GroupManager() g1 = manager.create("group1") g2 = manager.create("group2") sat = MockSatellite("sat1") g1.add(sat) g2.add(sat) groups = manager.get_groups_for_satellite("sat1") assert len(groups) == 2 def test_delete_group(self): """Test Gruppe löschen.""" from trixy_core.satellite.groups import GroupManager manager = GroupManager() manager.create("test") deleted = manager.delete("test") assert deleted is not None assert "test" not in manager def test_find_groups(self): """Test Gruppen suchen.""" from trixy_core.satellite.groups import GroupManager, GroupType manager = GroupManager() manager.create("g1", group_type=GroupType.ROOM) g2 = manager.create("g2", group_type=GroupType.ZONE) g2.add(MockSatellite("sat1", is_connected=True)) # Nach Typ filtern rooms = manager.find_groups(group_type=GroupType.ROOM) assert len(rooms) == 1 # Mit verbundenen Satellites with_connected = manager.find_groups(has_connected=True) assert len(with_connected) == 1 # ============================================================================ # Zone Tests # ============================================================================ class TestZone: """Tests für Zone.""" def test_create_zone(self): """Test Zone erstellen.""" from trixy_core.satellite.groups import Zone zone = Zone( zone_id="erdgeschoss", name="Erdgeschoss", rooms=["wohnzimmer", "küche"] ) assert zone.id == "erdgeschoss" assert zone.name == "Erdgeschoss" assert "wohnzimmer" in zone.rooms assert "küche" in zone.rooms def test_add_remove_room(self): """Test Raum hinzufügen/entfernen.""" from trixy_core.satellite.groups import Zone zone = Zone("test") zone.add_room("flur") assert "flur" in zone.rooms zone.remove_room("flur") assert "flur" not in zone.rooms def test_zone_hierarchy(self): """Test Zone-Hierarchie.""" from trixy_core.satellite.groups import Zone parent = Zone("haus", "Gesamtes Haus") child = Zone("erdgeschoss", "Erdgeschoss", rooms=["wohnzimmer"]) parent.add_child(child) assert child.parent is parent assert child in parent.children assert child.depth == 1 assert parent.depth == 0 assert parent.is_root assert not child.is_root def test_all_rooms_recursive(self): """Test alle Räume rekursiv.""" from trixy_core.satellite.groups import Zone parent = Zone("haus", rooms=["garage"]) child1 = Zone("eg", rooms=["wohnzimmer", "küche"]) child2 = Zone("og", rooms=["schlafzimmer", "bad"]) parent.add_child(child1) parent.add_child(child2) all_rooms = parent.all_rooms assert "garage" in all_rooms assert "wohnzimmer" in all_rooms assert "schlafzimmer" in all_rooms assert len(all_rooms) == 5 def test_contains_room_recursive(self): """Test contains_room rekursiv.""" from trixy_core.satellite.groups import Zone parent = Zone("haus") child = Zone("eg", rooms=["wohnzimmer"]) parent.add_child(child) assert parent.contains_room("wohnzimmer", recursive=True) assert not parent.contains_room("wohnzimmer", recursive=False) assert "wohnzimmer" in parent # __contains__ ist rekursiv def test_get_path(self): """Test Pfad zu Zone.""" from trixy_core.satellite.groups import Zone root = Zone("haus") level1 = Zone("eg") level2 = Zone("wohnbereich") root.add_child(level1) level1.add_child(level2) path = level2.get_path() assert len(path) == 3 assert path[0] is root assert path[2] is level2 class TestZoneManager: """Tests für ZoneManager.""" def test_create_zone(self): """Test Zone erstellen.""" from trixy_core.satellite.groups import ZoneManager manager = ZoneManager() zone = manager.create("test", "Test Zone", rooms=["raum1"]) assert zone.id == "test" assert manager.count == 1 assert "test" in manager def test_create_with_parent(self): """Test Zone mit Parent erstellen.""" from trixy_core.satellite.groups import ZoneManager manager = ZoneManager() parent = manager.create("parent") child = manager.create("child", parent="parent") assert child.parent is parent assert child in parent.children def test_invalid_parent_raises(self): """Test ungültiger Parent wirft Fehler.""" from trixy_core.satellite.groups import ZoneManager manager = ZoneManager() with pytest.raises(ValueError): manager.create("child", parent="nonexistent") def test_get_zone_for_room(self): """Test Zone für Raum finden.""" from trixy_core.satellite.groups import ZoneManager manager = ZoneManager() zone = manager.create("eg", rooms=["wohnzimmer", "küche"]) found = manager.get_zone_for_room("wohnzimmer") assert found is zone not_found = manager.get_zone_for_room("garage") assert not_found is None def test_move_room(self): """Test Raum verschieben.""" from trixy_core.satellite.groups import ZoneManager manager = ZoneManager() z1 = manager.create("z1", rooms=["raum1"]) z2 = manager.create("z2") assert manager.move_room("raum1", "z2") is True assert "raum1" not in z1.rooms assert "raum1" in z2.rooms def test_delete_zone(self): """Test Zone löschen.""" from trixy_core.satellite.groups import ZoneManager manager = ZoneManager() manager.create("test") deleted = manager.delete("test") assert deleted is not None assert "test" not in manager def test_delete_recursive(self): """Test rekursives Löschen.""" from trixy_core.satellite.groups import ZoneManager manager = ZoneManager() manager.create("parent") manager.create("child1", parent="parent") manager.create("child2", parent="parent") manager.delete("parent", recursive=True) assert manager.count == 0 def test_get_hierarchy(self): """Test Hierarchie abrufen.""" from trixy_core.satellite.groups import ZoneManager manager = ZoneManager() manager.create("root1", rooms=["r1"]) manager.create("child", parent="root1", rooms=["r2"]) manager.create("root2", rooms=["r3"]) hierarchy = manager.get_hierarchy() assert len(hierarchy["root_zones"]) == 2 # ============================================================================ # LoadBalancing Tests # ============================================================================ class TestLoadBalancingStrategy: """Tests für Load-Balancing-Strategien.""" def test_round_robin(self): """Test Round-Robin-Strategie.""" from trixy_core.satellite.loadbalancing import RoundRobinStrategy strategy = RoundRobinStrategy() satellites = [ MockSatellite("sat1"), MockSatellite("sat2"), MockSatellite("sat3"), ] # Sollte in Reihenfolge durchgehen results = [] for _ in range(6): result = strategy.select(satellites) results.append(result.satellite.id) # Jeder Satellite sollte 2x vorkommen assert results.count("sat1") == 2 assert results.count("sat2") == 2 assert results.count("sat3") == 2 def test_round_robin_handles_removed_satellite(self): """Test Round-Robin bei entferntem Satellite.""" from trixy_core.satellite.loadbalancing import RoundRobinStrategy strategy = RoundRobinStrategy() satellites = [MockSatellite("sat1"), MockSatellite("sat2")] strategy.select(satellites) # sat1 # Satellite entfernen satellites = [MockSatellite("sat2")] result = strategy.select(satellites) assert result.satellite.id == "sat2" def test_round_robin_empty_list(self): """Test Round-Robin mit leerer Liste.""" from trixy_core.satellite.loadbalancing import RoundRobinStrategy strategy = RoundRobinStrategy() result = strategy.select([]) assert result is None def test_least_connections(self): """Test Least-Connections-Strategie.""" from trixy_core.satellite.loadbalancing import LeastConnectionsStrategy strategy = LeastConnectionsStrategy() satellites = [ MockSatellite("sat1"), MockSatellite("sat2"), MockSatellite("sat3"), ] # Initial alle gleich, erstes ausgewählt result1 = strategy.select(satellites) strategy.on_request_start(result1.satellite) # Jetzt sollte sat2 oder sat3 gewählt werden (weniger Connections) result2 = strategy.select(satellites) assert result2.satellite.id != "sat1" def test_least_connections_end_request(self): """Test Least-Connections Connection-Tracking.""" from trixy_core.satellite.loadbalancing import LeastConnectionsStrategy strategy = LeastConnectionsStrategy() sat = MockSatellite("sat1") strategy.on_request_start(sat) assert strategy.get_connection_count("sat1") == 1 strategy.on_request_end(sat) assert strategy.get_connection_count("sat1") == 0 def test_weighted_strategy(self): """Test Weighted-Strategie.""" from trixy_core.satellite.loadbalancing import WeightedStrategy, WeightConfig strategy = WeightedStrategy() # Hohe Gewichtung für sat1 strategy.set_weight("sat1", WeightConfig(weight=10.0)) strategy.set_weight("sat2", WeightConfig(weight=1.0)) satellites = [MockSatellite("sat1"), MockSatellite("sat2")] # sat1 sollte deutlich öfter gewählt werden counts = {"sat1": 0, "sat2": 0} for _ in range(100): result = strategy.select(satellites) counts[result.satellite.id] += 1 # sat1 sollte ca. 90% bekommen assert counts["sat1"] > counts["sat2"] * 5 def test_weighted_zero_weight(self): """Test Weighted mit Gewicht 0.""" from trixy_core.satellite.loadbalancing import WeightedStrategy, WeightConfig strategy = WeightedStrategy() strategy.set_weight("sat1", WeightConfig(weight=0.0)) strategy.set_weight("sat2", WeightConfig(weight=1.0)) satellites = [MockSatellite("sat1"), MockSatellite("sat2")] # Nur sat2 sollte gewählt werden for _ in range(10): result = strategy.select(satellites) assert result.satellite.id == "sat2" class TestLoadBalancer: """Tests für LoadBalancer.""" def test_load_balancer_select(self): """Test LoadBalancer Auswahl.""" from trixy_core.satellite.loadbalancing import ( LoadBalancer, RoundRobinStrategy ) balancer = LoadBalancer(RoundRobinStrategy()) satellites = [MockSatellite("sat1"), MockSatellite("sat2")] result = balancer.select(satellites) assert result is not None assert result.satellite.id in ["sat1", "sat2"] def test_load_balancer_filters_disconnected(self): """Test LoadBalancer filtert nicht verbundene automatisch.""" from trixy_core.satellite.loadbalancing import ( LoadBalancer, RoundRobinStrategy ) balancer = LoadBalancer(RoundRobinStrategy()) satellites = [ MockSatellite("sat1", is_connected=False), MockSatellite("sat2", is_connected=True), ] # LoadBalancer filtert disconnected automatisch result = balancer.select(satellites) assert result.satellite.id == "sat2" def test_load_balancer_change_strategy(self): """Test Strategie-Wechsel.""" from trixy_core.satellite.loadbalancing import ( LoadBalancer, RoundRobinStrategy, LeastConnectionsStrategy ) balancer = LoadBalancer(RoundRobinStrategy()) assert balancer.strategy.name == "round_robin" # Strategie über Property-Setter ändern balancer.strategy = LeastConnectionsStrategy() assert balancer.strategy.name == "least_connections" # ============================================================================ # Health Tests # ============================================================================ class TestHealthMetrics: """Tests für HealthMetrics.""" def test_create_metrics(self): """Test Metriken erstellen.""" from trixy_core.satellite.health import HealthMetrics metrics = HealthMetrics( latency_ms=50.0, packet_loss=0.01, uptime=0.99, ) assert metrics.latency_ms == 50.0 assert metrics.packet_loss == 0.01 assert metrics.uptime == 0.99 def test_custom_metrics(self): """Test benutzerdefinierte Metriken.""" from trixy_core.satellite.health import HealthMetrics metrics = HealthMetrics() metrics.custom_metrics["audio_quality"] = 0.95 assert metrics.custom_metrics["audio_quality"] == 0.95 class TestHealthScorer: """Tests für HealthScorer.""" def test_calculate_score(self): """Test Score-Berechnung.""" from trixy_core.satellite.health import HealthScorer, HealthMetrics scorer = HealthScorer() metrics = HealthMetrics( latency_ms=50.0, packet_loss=0.0, uptime=1.0, error_rate=0.0, ) # Metriken zuerst speichern scorer.update_metrics("sat1", metrics) score = scorer.calculate_score("sat1") # Bei perfekten Werten sollte Score hoch sein assert score.overall > 80 def test_health_state_mapping(self): """Test Health-State-Zuordnung.""" from trixy_core.satellite.health import ( HealthScorer, HealthMetrics, HealthState ) scorer = HealthScorer() # Gute Metriken -> HEALTHY good = HealthMetrics(latency_ms=20, packet_loss=0, uptime=1.0) scorer.update_metrics("sat_good", good) score = scorer.calculate_score("sat_good") assert score.state == HealthState.HEALTHY # Mittelmäßige Metriken -> DEGRADED degraded = HealthMetrics(latency_ms=500, packet_loss=0.2, uptime=0.5) scorer.update_metrics("sat_degraded", degraded) score = scorer.calculate_score("sat_degraded") assert score.state in [HealthState.DEGRADED, HealthState.UNHEALTHY] # Sehr schlechte Metriken -> CRITICAL oder UNHEALTHY critical = HealthMetrics( latency_ms=1000, packet_loss=0.5, uptime=0.3, error_rate=0.5, last_heartbeat_age=120.0, ) scorer.update_metrics("sat_critical", critical) score = scorer.calculate_score("sat_critical") assert score.state in [HealthState.CRITICAL, HealthState.UNHEALTHY] def test_custom_weights(self): """Test benutzerdefinierte Gewichtung.""" from trixy_core.satellite.health import HealthScorer, HealthMetrics scorer = HealthScorer(weights={ "latency": 0.8, "packet_loss": 0.1, "uptime": 0.1, }) metrics = HealthMetrics(latency_ms=200, packet_loss=0.0, uptime=1.0) scorer.update_metrics("sat1", metrics) score = scorer.calculate_score("sat1") # Mit hoher Latenz-Gewichtung sollte Score niedriger sein assert score.overall < 80 def test_score_history(self): """Test Score-Historie.""" from trixy_core.satellite.health import HealthScorer, HealthMetrics scorer = HealthScorer(history_size=5) for i in range(3): metrics = HealthMetrics(latency_ms=50 + i * 10) scorer.update_metrics("sat1", metrics) scorer.calculate_score("sat1") history = scorer.get_history("sat1") assert len(history) == 3 class TestHealthThresholds: """Tests für HealthThresholds.""" def test_default_thresholds(self): """Test Standard-Schwellenwerte.""" from trixy_core.satellite.health import HealthThresholds thresholds = HealthThresholds() # Sollte Default-Configs haben assert thresholds.get("latency_ms") is not None assert thresholds.get("packet_loss") is not None def test_add_threshold(self): """Test Schwellenwert hinzufügen.""" from trixy_core.satellite.health import ( HealthThresholds, ThresholdConfig ) thresholds = HealthThresholds(use_defaults=False) config = ThresholdConfig( metric_name="custom_metric", warning_threshold=50.0, error_threshold=80.0, ) thresholds.add(config) assert thresholds.get("custom_metric") is config def test_check_violations(self): """Test Schwellenwert-Verletzung erkennen.""" from trixy_core.satellite.health import ( HealthThresholds, HealthMetrics, ThresholdConfig, ViolationSeverity ) thresholds = HealthThresholds(use_defaults=False) thresholds.add(ThresholdConfig( metric_name="latency_ms", warning_threshold=100.0, error_threshold=200.0, critical_threshold=500.0, )) # Über Warning-Schwelle metrics = HealthMetrics(latency_ms=150.0) violations = thresholds.check("sat1", metrics) assert len(violations) == 1 assert violations[0].severity == ViolationSeverity.WARNING def test_violation_callback(self): """Test Callback bei Verletzung.""" from trixy_core.satellite.health import ( HealthThresholds, HealthMetrics, ThresholdConfig ) thresholds = HealthThresholds(use_defaults=False) thresholds.add(ThresholdConfig( metric_name="latency_ms", warning_threshold=100.0, )) violations_received = [] @thresholds.on_violation def handler(violation): violations_received.append(violation) metrics = HealthMetrics(latency_ms=150.0) thresholds.check("sat1", metrics) assert len(violations_received) == 1 def test_cooldown(self): """Test Cooldown zwischen Alarmen.""" from trixy_core.satellite.health import ( HealthThresholds, HealthMetrics, ThresholdConfig ) thresholds = HealthThresholds(use_defaults=False) thresholds.add(ThresholdConfig( metric_name="latency_ms", warning_threshold=100.0, cooldown=300.0, # 5 Minuten )) metrics = HealthMetrics(latency_ms=150.0) # Erste Prüfung: Verletzung v1 = thresholds.check("sat1", metrics) assert len(v1) == 1 # Zweite Prüfung: Cooldown aktiv, keine Verletzung v2 = thresholds.check("sat1", metrics) assert len(v2) == 0 # Cooldown zurücksetzen thresholds.clear_cooldowns("sat1") v3 = thresholds.check("sat1", metrics) assert len(v3) == 1 # ============================================================================ # Failover Tests # ============================================================================ class TestBackupRegistry: """Tests für BackupRegistry.""" def test_assign_backup(self): """Test Backup zuweisen.""" from trixy_core.satellite.failover import BackupRegistry registry = BackupRegistry() assignment = registry.assign("primary1", "backup1", priority=10) assert assignment.primary_id == "primary1" assert assignment.backup_id == "backup1" assert assignment.priority == 10 def test_assign_self_fails(self): """Test Self-Assignment verhindert.""" from trixy_core.satellite.failover import BackupRegistry registry = BackupRegistry() with pytest.raises(ValueError): registry.assign("sat1", "sat1") def test_multiple_backups_priority(self): """Test mehrere Backups nach Priorität.""" from trixy_core.satellite.failover import BackupRegistry registry = BackupRegistry() registry.assign("primary", "backup1", priority=5) registry.assign("primary", "backup2", priority=10) registry.assign("primary", "backup3", priority=1) backups = registry.get_backups("primary") # Sollte nach Priorität sortiert sein assert backups[0].backup_id == "backup2" # priority=10 assert backups[1].backup_id == "backup1" # priority=5 assert backups[2].backup_id == "backup3" # priority=1 def test_get_best_backup(self): """Test bestes Backup abrufen.""" from trixy_core.satellite.failover import BackupRegistry def get_satellite(sat_id): if sat_id == "backup1": return MockSatellite(sat_id, is_connected=False) return MockSatellite(sat_id, is_connected=True) registry = BackupRegistry(get_satellite=get_satellite) registry.assign("primary", "backup1", priority=10) # disconnected registry.assign("primary", "backup2", priority=5) # connected best = registry.get_best_backup("primary") # backup1 hat höhere Priorität aber ist disconnected assert best.backup_id == "backup2" def test_unassign_backup(self): """Test Backup-Zuweisung entfernen.""" from trixy_core.satellite.failover import BackupRegistry registry = BackupRegistry() registry.assign("primary", "backup1") registry.assign("primary", "backup2") removed = registry.unassign("primary", "backup1") assert removed == 1 backups = registry.get_backups("primary") assert len(backups) == 1 def test_unassign_all(self): """Test alle Backups entfernen.""" from trixy_core.satellite.failover import BackupRegistry registry = BackupRegistry() registry.assign("primary", "backup1") registry.assign("primary", "backup2") removed = registry.unassign("primary") assert removed == 2 assert len(registry.get_backups("primary")) == 0 def test_backup_strategies(self): """Test Backup-Strategien.""" from trixy_core.satellite.failover import ( BackupRegistry, BackupStrategy ) def get_all(): return [ MockSatellite("sat1", room_id="room1"), MockSatellite("sat2", room_id="room1"), MockSatellite("sat3", room_id="room2"), ] def get_sat(sat_id): sats = {s.id: s for s in get_all()} return sats.get(sat_id) registry = BackupRegistry( default_strategy=BackupStrategy.SAME_ROOM, get_satellite=get_sat, get_all_satellites=get_all, ) # sat1 sucht Backup in room1 backup = registry.get_best_backup("sat1") assert backup is not None assert backup.backup_id == "sat2" # Im selben Raum def test_update_health_score(self): """Test Health-Score für Backup-Auswahl.""" from trixy_core.satellite.failover import ( BackupRegistry, BackupStrategy ) satellites = [ MockSatellite("sat1"), MockSatellite("sat2"), MockSatellite("sat3"), ] registry = BackupRegistry( default_strategy=BackupStrategy.HEALTH_BASED, get_satellite=lambda id: next((s for s in satellites if s.id == id), None), get_all_satellites=lambda: satellites, ) registry.update_health_score("sat2", 90.0) registry.update_health_score("sat3", 50.0) backup = registry.get_best_backup("sat1") assert backup.backup_id == "sat2" # Höherer Health-Score class TestFailoverManager: """Tests für FailoverManager.""" @pytest.fixture def setup_manager(self): """Fixture für FailoverManager Setup.""" from trixy_core.satellite.failover import ( FailoverManager, FailoverConfig, BackupRegistry ) satellites = { "primary1": MockSatellite("primary1"), "backup1": MockSatellite("backup1"), "backup2": MockSatellite("backup2"), } registry = BackupRegistry( get_satellite=lambda id: satellites.get(id), get_all_satellites=lambda: list(satellites.values()), ) registry.assign("primary1", "backup1", priority=10) registry.assign("primary1", "backup2", priority=5) manager = FailoverManager( backup_registry=registry, config=FailoverConfig(detection_timeout=5.0), get_satellite=lambda id: satellites.get(id), ) return manager, satellites @pytest.mark.asyncio async def test_trigger_failover(self, setup_manager): """Test Failover auslösen.""" from trixy_core.satellite.failover import FailoverState manager, _ = setup_manager event = await manager.trigger_failover( "primary1", reason="Test-Failover" ) assert event is not None assert event.primary_id == "primary1" assert event.backup_id == "backup1" # Höchste Priorität assert event.state == FailoverState.ACTIVE @pytest.mark.asyncio async def test_failover_no_backup_available(self, setup_manager): """Test Failover ohne verfügbares Backup.""" from trixy_core.satellite.failover import FailoverState manager, satellites = setup_manager # Beide Backups disconnecten satellites["backup1"].is_connected = False satellites["backup2"].is_connected = False event = await manager.trigger_failover("primary1") assert event.state == FailoverState.FAILED assert "Kein Backup" in event.reason @pytest.mark.asyncio async def test_failover_callback(self, setup_manager): """Test Failover-Callback.""" manager, _ = setup_manager events_received = [] @manager.on_failover async def handler(event): events_received.append(event) await manager.trigger_failover("primary1") assert len(events_received) == 1 @pytest.mark.asyncio async def test_recover(self, setup_manager): """Test Recovery nach Failover.""" from trixy_core.satellite.failover import FailoverState manager, satellites = setup_manager # Failover auslösen await manager.trigger_failover("primary1") assert "primary1" in manager.active_failovers # Recovery success = await manager.recover("primary1") assert success is True assert "primary1" not in manager.active_failovers @pytest.mark.asyncio async def test_prevent_duplicate_failover(self, setup_manager): """Test verhindert doppeltes Failover.""" manager, _ = setup_manager event1 = await manager.trigger_failover("primary1") event2 = await manager.trigger_failover("primary1") # Sollte das gleiche Event zurückgeben assert event1 is event2 @pytest.mark.asyncio async def test_get_history(self, setup_manager): """Test Failover-Historie.""" manager, _ = setup_manager await manager.trigger_failover("primary1", reason="Test 1") await manager.recover("primary1") history = manager.get_history() assert len(history) >= 1 def test_get_statistics(self, setup_manager): """Test Statistiken.""" manager, _ = setup_manager stats = manager.get_statistics() assert "total_failovers" in stats assert "successful" in stats assert "failed" in stats assert "success_rate" in stats # ============================================================================ # Integration Tests # ============================================================================ class TestSatelliteExtensionsIntegration: """Integration-Tests für alle Satellite-Extensions.""" def test_groups_with_loadbalancing(self): """Test Gruppen mit Load-Balancing.""" from trixy_core.satellite.groups import SatelliteGroup from trixy_core.satellite.loadbalancing import ( LoadBalancer, RoundRobinStrategy ) group = SatelliteGroup("test") group.add(MockSatellite("sat1")) group.add(MockSatellite("sat2")) group.add(MockSatellite("sat3")) balancer = LoadBalancer(RoundRobinStrategy()) # Load-Balancing über Gruppe result = balancer.select(group.get_connected()) assert result is not None def test_health_triggers_failover(self): """Test Health löst Failover aus.""" from trixy_core.satellite.health import ( HealthScorer, HealthMetrics, HealthState ) from trixy_core.satellite.failover import ( FailoverManager, FailoverConfig, BackupRegistry ) # Health Score berechnen scorer = HealthScorer() bad_metrics = HealthMetrics( latency_ms=600, packet_loss=0.15, error_rate=0.1, ) scorer.update_metrics("sat1", bad_metrics) score = scorer.calculate_score("sat1") # Bei kritischem Zustand sollte Failover möglich sein if score.state in [HealthState.CRITICAL, HealthState.UNHEALTHY]: registry = BackupRegistry() manager = FailoverManager( backup_registry=registry, config=FailoverConfig(health_threshold=40.0), ) # Überprüfung: Score liegt unter Threshold? assert score.overall < manager.config.health_threshold or score.state != HealthState.HEALTHY def test_zone_hierarchy_with_groups(self): """Test Zone-Hierarchie mit Gruppen.""" from trixy_core.satellite.groups import ( Zone, ZoneManager, GroupManager ) # Zonen erstellen zones = ZoneManager() zones.create("haus", "Gesamtes Haus") zones.create("eg", "Erdgeschoss", rooms=["wohnzimmer", "küche"], parent="haus") zones.create("og", "Obergeschoss", rooms=["schlafzimmer"], parent="haus") # Gruppen für Räume groups = GroupManager() groups.create_room_group("wohnzimmer") groups.create_room_group("küche") # Satellites zuweisen sat1 = MockSatellite("sat1", room_id="wohnzimmer") sat2 = MockSatellite("sat2", room_id="küche") groups.auto_assign(sat1) groups.auto_assign(sat2) # Zone sollte beide Räume enthalten eg = zones.get("eg") assert "wohnzimmer" in eg assert "küche" in eg if __name__ == "__main__": pytest.main([__file__, "-v"])