First upload, 18 controller version
This commit is contained in:
BIN
tests/__pycache__/test_controller.cpython-310.pyc
Normal file
BIN
tests/__pycache__/test_controller.cpython-310.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_ddp_output.cpython-310.pyc
Normal file
BIN
tests/__pycache__/test_ddp_output.cpython-310.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_device_assignment.cpython-310.pyc
Normal file
BIN
tests/__pycache__/test_device_assignment.cpython-310.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_geometry.cpython-310.pyc
Normal file
BIN
tests/__pycache__/test_geometry.cpython-310.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_output_manager.cpython-310.pyc
Normal file
BIN
tests/__pycache__/test_output_manager.cpython-310.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_pattern_engine.cpython-310.pyc
Normal file
BIN
tests/__pycache__/test_pattern_engine.cpython-310.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_preview_widget.cpython-310.pyc
Normal file
BIN
tests/__pycache__/test_preview_widget.cpython-310.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_realtime_output.cpython-310.pyc
Normal file
BIN
tests/__pycache__/test_realtime_output.cpython-310.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_wled_discovery.cpython-310.pyc
Normal file
BIN
tests/__pycache__/test_wled_discovery.cpython-310.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_xml_mapping.cpython-310.pyc
Normal file
BIN
tests/__pycache__/test_xml_mapping.cpython-310.pyc
Normal file
Binary file not shown.
181
tests/test_controller.py
Normal file
181
tests/test_controller.py
Normal 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
56
tests/test_ddp_output.py
Normal 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
tests/test_device_assignment.py
Normal file
51
tests/test_device_assignment.py
Normal 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
51
tests/test_geometry.py
Normal 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
tests/test_output_manager.py
Normal file
156
tests/test_output_manager.py
Normal 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
1168
tests/test_pattern_engine.py
Normal file
File diff suppressed because it is too large
Load Diff
95
tests/test_preview_widget.py
Normal file
95
tests/test_preview_widget.py
Normal 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
tests/test_realtime_output.py
Normal file
205
tests/test_realtime_output.py
Normal 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
tests/test_wled_discovery.py
Normal file
57
tests/test_wled_discovery.py
Normal 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
60
tests/test_xml_mapping.py
Normal 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()
|
||||
Reference in New Issue
Block a user