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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

181
tests/test_controller.py Normal file
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
tests/test_ddp_output.py Normal file
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()

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
tests/test_geometry.py Normal file
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()

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()

1168
tests/test_pattern_engine.py Normal file

File diff suppressed because it is too large Load Diff

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()

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()

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
tests/test_xml_mapping.py Normal file
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()