First upload, 18 controller version

This commit is contained in:
2026-04-14 15:23:56 +02:00
commit 8c55001a1c
3810 changed files with 764061 additions and 0 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+181
View File
@@ -0,0 +1,181 @@
from __future__ import annotations
from pathlib import Path
import unittest
from app.core.controller import InfinityMirrorController
from app.core.pattern_engine import PatternEngine
ROOT = Path(__file__).resolve().parents[1]
SAMPLE_MAPPING = ROOT / "sample_data" / "infinity_mirror_mapping_clean.xml"
class ControllerTests(unittest.TestCase):
def test_scene_state_returns_snapshot_instead_of_live_mutable_state(self) -> None:
controller = InfinityMirrorController(ROOT)
try:
controller.set_foh_mode(True)
controller.set_pattern("sparkle")
observed_events: list[str] = []
controller.state_changed.connect(lambda: observed_events.append("state"))
snapshot = controller.scene_state("next")
snapshot.pattern_id = "solid"
snapshot.params.primary_color = "#FFFFFF"
self.assertEqual(controller.scene_state("live").pattern_id, "solid")
self.assertEqual(controller.scene_state("next").pattern_id, "sparkle")
self.assertEqual(controller.scene_state("next").params.primary_color, "#4D7CFF")
self.assertEqual(observed_events, [])
finally:
controller.timer.stop()
def test_params_property_returns_snapshot_copy(self) -> None:
controller = InfinityMirrorController(ROOT)
try:
observed_events: list[str] = []
controller.state_changed.connect(lambda: observed_events.append("state"))
params = controller.params
params.strobe_duty_cycle = 0.12
params.flip_horizontal = True
self.assertEqual(controller.params.strobe_duty_cycle, 0.5)
self.assertFalse(controller.params.flip_horizontal)
self.assertEqual(observed_events, [])
finally:
controller.timer.stop()
def test_numeric_parameter_values_are_coerced_and_sanitized(self) -> None:
controller = InfinityMirrorController(ROOT)
try:
controller.set_parameter("strobe_duty_cycle", "0.99")
self.assertEqual(controller.params.strobe_duty_cycle, 0.98)
finally:
controller.timer.stop()
def test_invalid_numeric_parameter_value_is_ignored(self) -> None:
controller = InfinityMirrorController(ROOT)
try:
initial = controller.params.strobe_duty_cycle
controller.set_parameter("strobe_duty_cycle", "invalid")
self.assertEqual(controller.params.strobe_duty_cycle, initial)
finally:
controller.timer.stop()
def test_parameter_updates_preserve_existing_scan_flags(self) -> None:
controller = InfinityMirrorController(ROOT)
try:
controller.set_parameter("flip_horizontal", True)
controller.set_parameter("flip_vertical", True)
controller.set_parameter("band_thickness", 1.6)
controller.set_parameter("strobe_duty_cycle", 0.4)
params = controller.params
self.assertTrue(params.flip_horizontal)
self.assertTrue(params.flip_vertical)
self.assertEqual(params.band_thickness, 1.6)
self.assertEqual(params.strobe_duty_cycle, 0.4)
finally:
controller.timer.stop()
def test_single_preview_mode_keeps_next_scene_synced_with_live(self) -> None:
controller = InfinityMirrorController(ROOT)
try:
controller.set_pattern("sparkle")
controller.set_tempo_bpm(132.0)
self.assertEqual(controller.scene_state("live").pattern_id, "sparkle")
self.assertEqual(controller.scene_state("next").pattern_id, "sparkle")
self.assertEqual(controller.tempo_bpm, 132.0)
finally:
controller.timer.stop()
def test_foh_mode_edits_next_scene_without_touching_live(self) -> None:
controller = InfinityMirrorController(ROOT)
try:
controller.set_foh_mode(True)
controller.set_pattern("sparkle")
controller.set_tempo_bpm(140.0)
self.assertEqual(controller.scene_state("live").pattern_id, "solid")
self.assertEqual(controller.scene_state("next").pattern_id, "sparkle")
self.assertEqual(controller.pattern_id, "sparkle")
self.assertAlmostEqual(controller.tempo_bpm, 140.0)
finally:
controller.timer.stop()
def test_go_promotes_next_scene_to_live(self) -> None:
controller = InfinityMirrorController(ROOT)
try:
controller.set_foh_mode(True)
controller.set_pattern("sparkle")
controller.set_tempo_bpm(126.0)
controller.go_scene()
self.assertEqual(controller.scene_state("live").pattern_id, "sparkle")
self.assertAlmostEqual(controller.tempo_bpm, 126.0)
self.assertEqual(controller.scene_state("next").pattern_id, "sparkle")
finally:
controller.timer.stop()
def test_fade_go_commits_queued_scene_after_transition(self) -> None:
controller = InfinityMirrorController(ROOT)
try:
controller.load_mapping(ROOT / "sample_data" / "infinity_mirror_mapping_clean.xml")
controller.set_foh_mode(True)
controller.set_pattern("sparkle")
controller.set_tempo_bpm(144.0)
controller.render_once(timestamp=10.0)
controller.fade_go(0.5, timestamp=10.0)
self.assertTrue(controller.transition_active)
self.assertEqual(controller.scene_state("live").pattern_id, "solid")
self.assertEqual(controller.scene_state("next").pattern_id, "sparkle")
self.assertIsNotNone(controller.preview_frame_for("next"))
controller.render_once(timestamp=10.6)
self.assertFalse(controller.transition_active)
self.assertEqual(controller.scene_state("live").pattern_id, "sparkle")
self.assertEqual(controller.current_frame.pattern_id, "sparkle")
finally:
controller.timer.stop()
def test_legacy_speed_parameter_maps_to_global_tempo(self) -> None:
controller = InfinityMirrorController(ROOT)
try:
controller.set_parameter("speed", 2.0)
self.assertEqual(controller.tempo_bpm, 120.0)
finally:
controller.timer.stop()
def test_tempo_change_preserves_current_visual_phase(self) -> None:
controller = InfinityMirrorController(ROOT)
try:
controller.load_mapping(SAMPLE_MAPPING)
controller.set_pattern("center_pulse")
controller.set_parameter("color_mode", "mono")
controller.set_parameter("primary_color", "#FFFFFF")
controller.set_parameter("secondary_color", "#000000")
def active_tiles(timestamp: float) -> set[str]:
frame = controller._render_scene_frame(PatternEngine(), controller.scene_state("live"), timestamp)
return {
tile_id
for tile_id, tile in frame.tiles.items()
if max(tile.fill_color.to_8bit_tuple()) >= 240
}
before = active_tiles(10.0)
controller.set_tempo_bpm(240.0, timestamp=10.0)
after = active_tiles(10.0)
self.assertEqual(before, after)
advanced = active_tiles(10.26)
self.assertNotEqual(after, advanced)
finally:
controller.timer.stop()
if __name__ == "__main__":
unittest.main()
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
import copy
from pathlib import Path
import unittest
from app.config.xml_mapping import load_config
from app.core.pattern_engine import PatternEngine
from app.core.types import PatternParameters, RGBColor
from app.output.ddp import DDP_UNCHANGED_HOST_KEEPALIVE_S, DdpOutputBackend
ROOT = Path(__file__).resolve().parents[1]
SAMPLE_MAPPING = ROOT / "sample_data" / "infinity_mirror_mapping_clean.xml"
class DdpOutputBackendTests(unittest.TestCase):
def test_identical_frames_are_suppressed_until_keepalive_is_due(self) -> None:
config = load_config(SAMPLE_MAPPING)
frame = PatternEngine().render_frame(config, "solid", PatternParameters(color_mode="mono"), timestamp=0.0)
backend = DdpOutputBackend()
first_packets = backend._build_packets(config, frame)
second_packets = backend._build_packets(config, frame)
self.assertEqual(len(first_packets), 18)
self.assertEqual(len(second_packets), 0)
for host in list(backend._last_payload_sent_at):
backend._last_payload_sent_at[host] -= DDP_UNCHANGED_HOST_KEEPALIVE_S + 0.01
keepalive_packets = backend._build_packets(config, frame)
self.assertEqual(len(keepalive_packets), 18)
def test_only_changed_host_is_resent_before_keepalive(self) -> None:
config = load_config(SAMPLE_MAPPING)
backend = DdpOutputBackend()
frame = PatternEngine().render_frame(config, "solid", PatternParameters(color_mode="mono"), timestamp=0.0)
backend._build_packets(config, frame)
changed_frame = copy.deepcopy(frame)
black = RGBColor.black()
tile = changed_frame.tiles["r1c1"]
tile.led_pixels = {
segment_name: [black for _ in colors]
for segment_name, colors in tile.led_pixels.items()
}
changed_packets = backend._build_packets(config, changed_frame)
self.assertEqual(len(changed_packets), 1)
self.assertEqual(changed_packets[0][0], config.tile_lookup()["r1c1"].controller_ip)
if __name__ == "__main__":
unittest.main()
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
import unittest
from app.config.device_assignment import assign_device_to_tile, find_tile_for_device
from app.config.models import InfinityMirrorConfig, LogicalDisplayConfig, TileConfig
from app.network.wled import DiscoveredWledDevice
class DeviceAssignmentTests(unittest.TestCase):
def _config(self) -> InfinityMirrorConfig:
return InfinityMirrorConfig(
logical_display=LogicalDisplayConfig(rows=1, cols=2),
tiles=[
TileConfig(tile_id="r1c1", row=1, col=1, controller_ip="192.0.2.10", controller_mac="AA:BB:CC:DD:EE:01"),
TileConfig(tile_id="r1c2", row=1, col=2, controller_ip="192.0.2.11", controller_mac="AA:BB:CC:DD:EE:02"),
],
)
def test_find_tile_prefers_mac_match_over_stale_ip(self) -> None:
config = self._config()
device = DiscoveredWledDevice(ip_address="192.0.2.42", mac_address="AA:BB:CC:DD:EE:01")
matched = find_tile_for_device(config, device)
self.assertIsNotNone(matched)
self.assertEqual(matched.tile_id, "r1c1")
def test_assign_device_moves_previous_assignment_and_overwrites_target(self) -> None:
config = self._config()
device = DiscoveredWledDevice(
ip_address="192.0.2.42",
hostname="tile-42.local",
instance_name="Tile 42",
mac_address="AA:BB:CC:DD:EE:01",
)
assign_device_to_tile(config, device, "r1c2")
source_tile = config.tile_lookup()["r1c1"]
target_tile = config.tile_lookup()["r1c2"]
self.assertEqual(source_tile.controller_ip, "")
self.assertEqual(source_tile.controller_mac, "")
self.assertEqual(target_tile.controller_ip, "192.0.2.42")
self.assertEqual(target_tile.controller_host, "tile-42.local")
self.assertEqual(target_tile.controller_name, "Tile 42")
self.assertEqual(target_tile.controller_mac, "AA:BB:CC:DD:EE:01")
if __name__ == "__main__":
unittest.main()
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
from pathlib import Path
import unittest
from app.config.xml_mapping import load_config
from app.core.geometry import segment_led_positions, segment_side
ROOT = Path(__file__).resolve().parents[1]
SAMPLE_MAPPING = ROOT / "sample_data" / "infinity_mirror_mapping_clean.xml"
class GeometryTests(unittest.TestCase):
def test_segment_side_inference_matches_mapping_edges(self) -> None:
config = load_config(SAMPLE_MAPPING)
tile = config.sorted_tiles()[0]
self.assertEqual(
{
segment.name: segment_side(tile, segment)
for segment in tile.segments
},
{
"1 - 81 LED 27": "left",
"82 - 162 LED 27": "bottom",
"163 - 243 LED 27": "right",
"244 - 318 LED 25": "top",
},
)
def test_segment_led_positions_follow_counterclockwise_channel_order(self) -> None:
config = load_config(SAMPLE_MAPPING)
tile = config.sorted_tiles()[0]
positions_by_side = {
segment.side: segment_led_positions(tile, segment, insets=(0.02, 0.02))
for segment in tile.segments
}
self.assertEqual(positions_by_side["left"][0], (0.02, 0.02))
self.assertEqual(positions_by_side["left"][-1], (0.02, 0.98))
self.assertEqual(positions_by_side["bottom"][0], (0.02, 0.98))
self.assertEqual(positions_by_side["bottom"][-1], (0.98, 0.98))
self.assertEqual(positions_by_side["right"][0], (0.98, 0.98))
self.assertEqual(positions_by_side["right"][-1], (0.98, 0.02))
self.assertEqual(positions_by_side["top"][0], (0.98, 0.02))
self.assertEqual(positions_by_side["top"][-1], (0.02, 0.02))
if __name__ == "__main__":
unittest.main()
+156
View File
@@ -0,0 +1,156 @@
from __future__ import annotations
import threading
import time
import unittest
from app.config.models import InfinityMirrorConfig
from app.core.types import PreviewFrame, RGBColor
from app.output.base import OutputBackend, OutputResult
from app.output.manager import OutputManager
def _frame(pattern_id: str) -> PreviewFrame:
black = RGBColor.black()
return PreviewFrame(
timestamp=0.0,
pattern_id=pattern_id,
utility_mode="none",
background_start=black,
background_end=black,
tiles={},
)
class RecordingBackend(OutputBackend):
backend_id = "recording"
display_name = "Recording"
supports_live_output = True
def __init__(self, result: OutputResult | None = None, send_gate: threading.Event | None = None) -> None:
self.result = result if result is not None else OutputResult(ok=True, message="", packets_sent=1, device_count=1)
self.send_gate = send_gate
self.sent_patterns: list[str] = []
self.send_event = threading.Event()
self.start_count = 0
self.stop_count = 0
self._lock = threading.Lock()
def start(self) -> None:
with self._lock:
self.start_count += 1
def stop(self) -> None:
with self._lock:
self.stop_count += 1
def send_frame(self, config: InfinityMirrorConfig, frame: PreviewFrame) -> OutputResult:
if self.send_gate is not None:
self.send_gate.wait(0.5)
with self._lock:
self.sent_patterns.append(frame.pattern_id)
self.send_event.set()
return self.result
class TimingBackend(OutputBackend):
backend_id = "timing"
display_name = "Timing"
supports_live_output = True
def __init__(self, delay_s: float) -> None:
self.delay_s = delay_s
self.sent_at: list[float] = []
self.send_event = threading.Event()
def send_frame(self, config: InfinityMirrorConfig, frame: PreviewFrame) -> OutputResult:
time.sleep(self.delay_s)
self.sent_at.append(time.perf_counter())
self.send_event.set()
return OutputResult(ok=True, message="", packets_sent=1, device_count=1)
class OutputManagerTests(unittest.TestCase):
def tearDown(self) -> None:
manager = getattr(self, "manager", None)
if manager is not None:
manager.shutdown()
def test_latest_frame_overwrites_stale_pending_frame(self) -> None:
self.manager = OutputManager(target_fps=20.0)
backend = RecordingBackend()
self.manager.backends[backend.backend_id] = backend
self.manager.set_active_backend(backend.backend_id)
self.manager.set_output_enabled(True)
self.manager.submit_frame(_frame("solid"))
self.manager.submit_frame(_frame("sparkle"))
self.manager.update_config(InfinityMirrorConfig())
self.assertTrue(backend.send_event.wait(0.5))
diagnostics = self.manager.diagnostics_snapshot()
self.assertEqual(backend.sent_patterns[0], "sparkle")
self.assertEqual(diagnostics.stale_frame_drops, 1)
def test_diagnostics_report_selected_backend_and_packet_counts(self) -> None:
self.manager = OutputManager(target_fps=25.0)
backend = RecordingBackend(result=OutputResult(ok=True, message="ok", packets_sent=3, device_count=2))
self.manager.backends[backend.backend_id] = backend
self.manager.set_active_backend(backend.backend_id)
self.manager.update_config(InfinityMirrorConfig())
self.manager.set_output_enabled(True)
self.manager.submit_frame(_frame("solid"))
self.assertTrue(backend.send_event.wait(0.5))
diagnostics = self.manager.diagnostics_snapshot()
self.assertEqual(diagnostics.backend_id, backend.backend_id)
self.assertEqual(diagnostics.backend_name, backend.display_name)
self.assertTrue(diagnostics.worker_running)
self.assertEqual(diagnostics.packets_last_frame, 3)
self.assertEqual(diagnostics.devices_last_frame, 2)
self.assertGreaterEqual(diagnostics.frames_sent, 1)
def test_send_failures_are_exposed_as_status_messages(self) -> None:
self.manager = OutputManager(target_fps=25.0)
backend = RecordingBackend(result=OutputResult(ok=False, message="send failed", packets_sent=0, device_count=0))
self.manager.backends[backend.backend_id] = backend
self.manager.set_active_backend(backend.backend_id)
self.manager.update_config(InfinityMirrorConfig())
self.manager.set_output_enabled(True)
self.manager.submit_frame(_frame("solid"))
self.assertTrue(backend.send_event.wait(0.5))
self.assertIn("send failed", self.manager.drain_status_messages())
self.assertEqual(self.manager.diagnostics_snapshot().send_failures, 1)
def test_worker_keeps_target_cadence_instead_of_adding_send_time_to_every_interval(self) -> None:
self.manager = OutputManager(target_fps=25.0)
backend = TimingBackend(delay_s=0.012)
self.manager.backends[backend.backend_id] = backend
self.manager.set_active_backend(backend.backend_id)
self.manager.update_config(InfinityMirrorConfig())
self.manager.set_output_enabled(True)
started_at = time.perf_counter()
while time.perf_counter() - started_at < 0.75:
self.manager.submit_frame(_frame("solid"))
time.sleep(0.01)
self.assertTrue(backend.send_event.wait(0.5))
time.sleep(0.2)
intervals = [later - earlier for earlier, later in zip(backend.sent_at, backend.sent_at[1:])]
self.assertGreaterEqual(len(intervals), 8)
average_interval = sum(intervals) / len(intervals)
self.assertLess(average_interval, 0.05)
diagnostics = self.manager.diagnostics_snapshot()
self.assertLessEqual(diagnostics.send_budget_misses, 1)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
from pathlib import Path
import unittest
from app.config.xml_mapping import load_config
from app.qt_compat import QRectF
from app.ui.preview_widget import (
PREVIEW_MODE_LEDS,
PREVIEW_MODE_TECHNICAL,
PREVIEW_MODE_TILE,
PreviewWidget,
normalize_preview_mode,
)
ROOT = Path(__file__).resolve().parents[1]
SAMPLE_MAPPING = ROOT / "sample_data" / "infinity_mirror_mapping_clean.xml"
class PreviewWidgetTests(unittest.TestCase):
def test_preview_mode_normalization_defaults_to_tile(self) -> None:
self.assertEqual(normalize_preview_mode(PREVIEW_MODE_TILE), PREVIEW_MODE_TILE)
self.assertEqual(normalize_preview_mode(PREVIEW_MODE_TECHNICAL), PREVIEW_MODE_TECHNICAL)
self.assertEqual(normalize_preview_mode(PREVIEW_MODE_LEDS), PREVIEW_MODE_LEDS)
self.assertEqual(normalize_preview_mode("unknown"), PREVIEW_MODE_TILE)
def test_preview_mode_flags_keep_tile_and_led_views_separate(self) -> None:
widget = PreviewWidget.__new__(PreviewWidget)
widget.preview_mode = PREVIEW_MODE_TILE
self.assertEqual(
widget._mode_flags(),
{
"show_fill": True,
"show_labels": True,
"show_leds": False,
"show_guides": False,
"show_direction": False,
"show_overlay_title": False,
"show_technical_meta": False,
},
)
widget.preview_mode = PREVIEW_MODE_TECHNICAL
self.assertEqual(
widget._mode_flags(),
{
"show_fill": True,
"show_labels": True,
"show_leds": True,
"show_guides": True,
"show_direction": True,
"show_overlay_title": True,
"show_technical_meta": True,
},
)
widget.preview_mode = PREVIEW_MODE_LEDS
self.assertEqual(
widget._mode_flags(),
{
"show_fill": False,
"show_labels": False,
"show_leds": True,
"show_guides": False,
"show_direction": False,
"show_overlay_title": False,
"show_technical_meta": False,
},
)
def test_segment_points_follow_counterclockwise_perimeter_order(self) -> None:
config = load_config(SAMPLE_MAPPING)
tile = config.sorted_tiles()[0]
widget = PreviewWidget.__new__(PreviewWidget)
rect = QRectF(0.0, 0.0, 200.0, 200.0)
points_by_side = {
segment.side: PreviewWidget._segment_points_for_side(widget, tile, segment, rect)
for segment in tile.segments
}
self.assertEqual((round(points_by_side["left"][0].x(), 1), round(points_by_side["left"][0].y(), 1)), (4.0, 4.0))
self.assertEqual((round(points_by_side["left"][-1].x(), 1), round(points_by_side["left"][-1].y(), 1)), (4.0, 196.0))
self.assertEqual((round(points_by_side["bottom"][0].x(), 1), round(points_by_side["bottom"][0].y(), 1)), (4.0, 196.0))
self.assertEqual((round(points_by_side["bottom"][-1].x(), 1), round(points_by_side["bottom"][-1].y(), 1)), (196.0, 196.0))
self.assertEqual((round(points_by_side["right"][0].x(), 1), round(points_by_side["right"][0].y(), 1)), (196.0, 196.0))
self.assertEqual((round(points_by_side["right"][-1].x(), 1), round(points_by_side["right"][-1].y(), 1)), (196.0, 4.0))
self.assertEqual((round(points_by_side["top"][0].x(), 1), round(points_by_side["top"][0].y(), 1)), (196.0, 4.0))
self.assertEqual((round(points_by_side["top"][-1].x(), 1), round(points_by_side["top"][-1].y(), 1)), (4.0, 4.0))
if __name__ == "__main__":
unittest.main()
+205
View File
@@ -0,0 +1,205 @@
from __future__ import annotations
from pathlib import Path
import threading
import time
import unittest
from app.core.controller import InfinityMirrorController
from app.output.base import OutputBackend, OutputDiagnostics, OutputResult
from app.output.manager import OutputManager
ROOT = Path(__file__).resolve().parents[1]
SAMPLE_MAPPING = ROOT / "sample_data" / "infinity_mirror_mapping_clean.xml"
class _NamedBackend(OutputBackend):
backend_id = "preview"
display_name = "Preview Only"
supports_live_output = False
def send_frame(self, config, frame) -> OutputResult:
return OutputResult(ok=True)
class _LiveNamedBackend(OutputBackend):
backend_id = "live"
display_name = "Live Backend"
supports_live_output = True
def send_frame(self, config, frame) -> OutputResult:
return OutputResult(ok=True)
class RecordingOutputManager:
def __init__(self) -> None:
self.output_enabled = False
self.active_backend_id = "preview"
self._backend = _NamedBackend()
self.submitted_frames = []
def update_config(self, config) -> None:
self._config = config.clone()
def submit_frame(self, frame):
self.submitted_frames.append(frame)
return OutputResult(ok=True)
def drain_status_messages(self) -> list[str]:
return []
def diagnostics_snapshot(self) -> OutputDiagnostics:
return OutputDiagnostics(
backend_id=self.active_backend_id,
backend_name=self._backend.display_name,
output_enabled=self.output_enabled,
worker_running=False,
target_fps=25.0,
frames_submitted=len(self.submitted_frames),
controller_fps=22.5,
controller_live_devices=2,
controller_sampled_devices=2,
controller_total_devices=2,
controller_source="stub",
)
def set_active_backend(self, backend_id: str) -> None:
self.active_backend_id = backend_id
def set_output_enabled(self, enabled: bool) -> None:
self.output_enabled = bool(enabled)
def active_backend(self) -> OutputBackend:
return self._backend
def shutdown(self) -> None:
pass
class LiveRecordingOutputManager(RecordingOutputManager):
def __init__(self) -> None:
super().__init__()
self.active_backend_id = "live"
self._backend = _LiveNamedBackend()
class SlowBackend(OutputBackend):
backend_id = "slow"
display_name = "Slow Backend"
supports_live_output = True
def __init__(self, delay_s: float = 0.15) -> None:
self.delay_s = delay_s
self.send_event = threading.Event()
def send_frame(self, config, frame) -> OutputResult:
time.sleep(self.delay_s)
self.send_event.set()
return OutputResult(ok=True, packets_sent=1, device_count=1)
class RealtimeOutputTests(unittest.TestCase):
def test_foh_preview_does_not_submit_next_scene_to_hardware(self) -> None:
output_manager = RecordingOutputManager()
controller = InfinityMirrorController(ROOT, output_manager=output_manager)
try:
controller.timer.stop()
controller.load_mapping(SAMPLE_MAPPING)
controller.set_foh_mode(True)
controller.set_pattern("sparkle")
controller.render_once(timestamp=10.0)
self.assertTrue(output_manager.submitted_frames)
self.assertEqual(output_manager.submitted_frames[-1].pattern_id, "solid")
self.assertEqual(controller.preview_frame_for("next").pattern_id, "sparkle")
finally:
controller.shutdown()
def test_render_once_does_not_wait_for_slow_output_backend(self) -> None:
output_manager = OutputManager(target_fps=25.0)
slow_backend = SlowBackend(delay_s=0.15)
output_manager.backends[slow_backend.backend_id] = slow_backend
output_manager.set_active_backend(slow_backend.backend_id)
controller = InfinityMirrorController(ROOT, output_manager=output_manager)
try:
controller.timer.stop()
controller.load_mapping(SAMPLE_MAPPING)
controller.set_output_target_fps(20.0)
output_manager.set_output_enabled(True)
slow_backend.send_event.clear()
started_at = time.perf_counter()
controller.render_once(timestamp=10.0)
duration_s = time.perf_counter() - started_at
self.assertLess(duration_s, 0.08)
self.assertTrue(slow_backend.send_event.wait(0.5))
diagnostics = controller.realtime_diagnostics()
self.assertEqual(diagnostics.backend_id, slow_backend.backend_id)
self.assertEqual(diagnostics.target_output_fps, 20.0)
self.assertGreaterEqual(diagnostics.frames_rendered, 1)
self.assertEqual(diagnostics.packets_last_frame, 1)
finally:
controller.shutdown()
def test_realtime_diagnostics_include_controller_side_metrics_when_available(self) -> None:
output_manager = RecordingOutputManager()
controller = InfinityMirrorController(ROOT, output_manager=output_manager)
try:
controller.timer.stop()
controller.load_mapping(SAMPLE_MAPPING)
diagnostics = controller.realtime_diagnostics()
self.assertEqual(diagnostics.controller_fps, 22.5)
self.assertEqual(diagnostics.controller_live_devices, 2)
self.assertEqual(diagnostics.controller_total_devices, 2)
self.assertEqual(diagnostics.controller_source, "stub")
finally:
controller.shutdown()
def test_live_output_throttles_preview_signals_but_not_frame_submission(self) -> None:
output_manager = LiveRecordingOutputManager()
controller = InfinityMirrorController(ROOT, output_manager=output_manager)
try:
controller.timer.stop()
controller.load_mapping(SAMPLE_MAPPING)
controller.set_output_enabled(True)
emitted_timestamps: list[float] = []
controller.frame_ready.connect(lambda frame: emitted_timestamps.append(frame.timestamp))
output_manager.submitted_frames.clear()
for timestamp in (10.0, 10.02, 10.04, 10.09):
controller.render_once(timestamp=timestamp)
self.assertEqual(len(output_manager.submitted_frames), 4)
self.assertEqual(emitted_timestamps, [10.0, 10.09])
finally:
controller.shutdown()
def test_user_triggered_pattern_change_forces_preview_refresh_during_live_output(self) -> None:
output_manager = LiveRecordingOutputManager()
controller = InfinityMirrorController(ROOT, output_manager=output_manager)
try:
controller.timer.stop()
controller.load_mapping(SAMPLE_MAPPING)
controller.set_output_enabled(True)
controller.render_once(timestamp=10.0)
emitted_patterns: list[str] = []
controller.frame_ready.connect(lambda frame: emitted_patterns.append(frame.pattern_id))
controller.set_pattern("sparkle")
self.assertIn("sparkle", emitted_patterns)
finally:
controller.shutdown()
if __name__ == "__main__":
unittest.main()
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from app.network.wled import DiscoveredWledDevice, discover_wled_devices, fetch_wled_info, probe_wled_device
class WledDiscoveryTests(unittest.TestCase):
def test_fetch_wled_info_falls_back_to_json_endpoint(self) -> None:
with patch(
"app.network.wled._load_json",
side_effect=[
None,
{"info": {"name": "Tile 1", "mac": "AA:BB:CC:DD:EE:FF", "leds": {"count": 106}}},
],
):
result = fetch_wled_info("192.0.2.10")
self.assertIsNotNone(result)
payload, endpoint = result
self.assertEqual(endpoint, "/json")
self.assertEqual(payload["name"], "Tile 1")
def test_probe_wled_device_extracts_metadata(self) -> None:
with patch(
"app.network.wled.fetch_wled_info",
return_value=(
{"name": "Tile 1", "mac": "aa-bb-cc-dd-ee-ff", "leds": {"count": 106}},
"/json/info",
),
), patch("app.network.wled._reverse_dns_name", return_value="tile1.local"):
device = probe_wled_device("192.0.2.10")
self.assertIsNotNone(device)
self.assertEqual(device.ip_address, "192.0.2.10")
self.assertEqual(device.hostname, "tile1.local")
self.assertEqual(device.instance_name, "Tile 1")
self.assertEqual(device.mac_address, "AA:BB:CC:DD:EE:FF")
self.assertEqual(device.led_count, 106)
def test_discover_wled_devices_deduplicates_devices_by_mac(self) -> None:
responses = {
"host-a": DiscoveredWledDevice(ip_address="192.0.2.10", mac_address="AA:BB:CC:DD:EE:FF"),
"host-b": DiscoveredWledDevice(ip_address="192.0.2.10", mac_address="AA:BB:CC:DD:EE:FF"),
"host-c": None,
}
with patch("app.network.wled.probe_wled_device", side_effect=lambda host, timeout_s=0.35: responses[host]):
devices = discover_wled_devices(["host-a", "host-b", "host-c"], max_workers=1)
self.assertEqual(len(devices), 1)
self.assertEqual(devices[0].mac_address, "AA:BB:CC:DD:EE:FF")
if __name__ == "__main__":
unittest.main()
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from pathlib import Path
import unittest
from app.config.xml_mapping import config_to_xml_string, load_config, load_config_from_string, validate_config
ROOT = Path(__file__).resolve().parents[1]
SAMPLE_MAPPING = ROOT / "sample_data" / "infinity_mirror_mapping_clean.xml"
class XmlMappingTests(unittest.TestCase):
def test_sample_mapping_loads_and_validates(self) -> None:
config = load_config(SAMPLE_MAPPING)
self.assertEqual(config.logical_display.rows, 3)
self.assertEqual(config.logical_display.cols, 6)
self.assertEqual(len(config.tiles), 18)
self.assertTrue(validate_config(config).is_valid)
def test_round_trip_preserves_tile_count_and_ids(self) -> None:
config = load_config(SAMPLE_MAPPING)
xml_text = config_to_xml_string(config)
restored = load_config_from_string(xml_text)
self.assertEqual(len(restored.tiles), len(config.tiles))
self.assertEqual([tile.tile_id for tile in restored.sorted_tiles()], [tile.tile_id for tile in config.sorted_tiles()])
def test_round_trip_preserves_optional_controller_metadata(self) -> None:
config = load_config(SAMPLE_MAPPING)
tile = config.tile_lookup()["r1c1"]
tile.controller_host = "wled-r1c1.local"
tile.controller_name = "Front Left"
tile.controller_mac = "AA:BB:CC:DD:EE:FF"
restored = load_config_from_string(config_to_xml_string(config))
restored_tile = restored.tile_lookup()["r1c1"]
self.assertEqual(restored_tile.controller_ip, tile.controller_ip)
self.assertEqual(restored_tile.controller_host, "wled-r1c1.local")
self.assertEqual(restored_tile.controller_name, "Front Left")
self.assertEqual(restored_tile.controller_mac, "AA:BB:CC:DD:EE:FF")
def test_sample_mapping_first_row_is_not_left_right_mirrored(self) -> None:
config = load_config(SAMPLE_MAPPING)
first_row = [tile.screen_name for tile in config.sorted_tiles() if tile.row == 1]
self.assertEqual(
first_row,
[
"Lumiverse 1",
"Lumiverse 4",
"Lumiverse 7",
"Lumiverse 10",
"Lumiverse 13",
"Lumiverse 16",
],
)
if __name__ == "__main__":
unittest.main()