First upload, 18 controller version
This commit is contained in:
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.
Binary file not shown.
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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