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