from __future__ import annotations from pathlib import Path import unittest from app.core.colors import custom_random_color_choices from app.config.xml_mapping import load_config from app.core.pattern_engine import PatternEngine from app.core.types import PatternParameters from app.patterns.base import PatternContext from app.patterns.builtin import SnakePattern ROOT = Path(__file__).resolve().parents[1] SAMPLE_MAPPING = ROOT / "sample_data" / "infinity_mirror_mapping_clean.xml" RANDOM_EFFECT_COLORS = { (255, 30, 30), (255, 107, 0), (255, 188, 0), (178, 255, 0), (0, 255, 76), (0, 219, 255), (0, 81, 255), (178, 0, 255), } def _matches_scaled_random_effect_color(color: tuple[int, int, int]) -> bool: if color == (0, 0, 0): return False for base in RANDOM_EFFECT_COLORS: scales = [channel / reference for channel, reference in zip(color, base) if channel > 1 and reference > 0] if not scales: continue scale = sum(scales) / len(scales) if all(abs(channel - (reference * scale)) <= 3.0 for channel, reference in zip(color, base)): return True return False def _matches_scaled_choice_color(color: tuple[int, int, int], choices: set[tuple[int, int, int]]) -> bool: if color == (0, 0, 0): return False for base in choices: scales = [channel / reference for channel, reference in zip(color, base) if channel > 1 and reference > 0] if not scales: continue scale = sum(scales) / len(scales) if all(abs(channel - (reference * scale)) <= 3.0 for channel, reference in zip(color, base)): return True return False class PatternEngineTests(unittest.TestCase): def test_sample_mapping_tile_segments_follow_counterclockwise_band(self) -> None: config = load_config(SAMPLE_MAPPING) tile = config.sorted_tiles()[0] self.assertEqual( [(segment.side, segment.start_channel, segment.led_count, segment.reverse) for segment in tile.segments], [ ("left", 1, 27, False), ("bottom", 82, 27, False), ("right", 163, 27, True), ("top", 244, 25, True), ], ) def test_engine_renders_all_tiles_and_leds(self) -> None: config = load_config(SAMPLE_MAPPING) engine = PatternEngine() frame = engine.render_frame(config, "checker", PatternParameters(), timestamp=1.23) self.assertEqual(len(frame.tiles), 18) first_tile = frame.tiles["r1c1"] self.assertEqual(len(first_tile.led_pixels), 4) self.assertEqual(sum(len(colors) for colors in first_tile.led_pixels.values()), 106) def test_utility_single_tile_only_lights_selected_tile(self) -> None: config = load_config(SAMPLE_MAPPING) engine = PatternEngine() frame = engine.render_frame( config, "solid", PatternParameters(), utility_mode="single_tile", selected_tile_id="r2c3", timestamp=2.0, ) lit_tiles = [tile_id for tile_id, tile in frame.tiles.items() if tile.fill_color.to_8bit_tuple() != (0, 0, 0)] self.assertEqual(lit_tiles, ["r2c3"]) def test_solid_pattern_hits_selected_color_and_led_output(self) -> None: config = load_config(SAMPLE_MAPPING) engine = PatternEngine() params = PatternParameters( brightness=1.0, color_mode="dual", primary_color="#0000FF", secondary_color="#FF0000", ) frame = engine.render_frame(config, "solid", params, timestamp=3.0) first_tile = frame.tiles["r1c1"] self.assertEqual(first_tile.fill_color.to_8bit_tuple(), (0, 0, 255)) first_segment = next(iter(first_tile.led_pixels.values())) self.assertTrue(first_segment) self.assertEqual(first_segment[0].to_8bit_tuple(), (0, 0, 255)) def test_new_low_res_patterns_are_registered(self) -> None: engine = PatternEngine() descriptors = {descriptor.pattern_id: descriptor for descriptor in engine.descriptors()} ids = set(descriptors) self.assertTrue({"scan", "scan_dual", "saw", "sweep", "two_dots", "wave_line", "stopwatch", "snake", "strobe", "sparkle"}.issubset(ids)) self.assertIn("checker_mode", descriptors["checker"].supported_parameters) self.assertIn("angle", descriptors["scan"].supported_parameters) self.assertIn("tempo_multiplier", descriptors["scan"].supported_parameters) self.assertIn("on_width", descriptors["scan"].supported_parameters) self.assertIn("off_width", descriptors["scan"].supported_parameters) self.assertNotIn("speed", descriptors["scan"].supported_parameters) self.assertNotIn("step_size", descriptors["scan"].supported_parameters) self.assertNotIn("band_thickness", descriptors["scan"].supported_parameters) self.assertNotIn("flip_horizontal", descriptors["scan"].supported_parameters) self.assertNotIn("flip_vertical", descriptors["scan"].supported_parameters) self.assertNotIn("level_mode", descriptors["scan"].supported_parameters) self.assertIn("strobe_mode", descriptors["strobe"].supported_parameters) self.assertNotIn("level_mode", descriptors["strobe"].supported_parameters) self.assertNotIn("randomness", descriptors["strobe"].supported_parameters) self.assertIn("pixel_group_size", descriptors["strobe"].supported_parameters) self.assertIn("strobe_duty_cycle", descriptors["strobe"].supported_parameters) self.assertIn("center_pulse_mode", descriptors["center_pulse"].supported_parameters) self.assertNotIn("symmetry", descriptors["center_pulse"].supported_parameters) self.assertIn("stopwatch_mode", descriptors["stopwatch"].supported_parameters) self.assertIn("tempo_multiplier", descriptors["stopwatch"].supported_parameters) self.assertNotIn("level_mode", descriptors["stopwatch"].supported_parameters) self.assertNotIn("randomness", descriptors["sparkle"].supported_parameters) self.assertIn("strobe_duty_cycle", descriptors["sparkle"].supported_parameters) self.assertNotIn("symmetry", descriptors["checker"].supported_parameters) def test_checker_diagonal_split_reaches_preview_metadata_and_leds(self) -> None: config = load_config(SAMPLE_MAPPING) engine = PatternEngine() params = PatternParameters( brightness=1.0, speed=1.0, step_size=1.0, checker_mode="diagonal", color_mode="dual", primary_color="#FFFFFF", secondary_color="#000000", ) frame = engine.render_frame(config, "checker", params, timestamp=0.0) tile = frame.tiles["r1c1"] diagonal_split = tile.metadata.get("diagonal_split") self.assertIsInstance(diagonal_split, dict) self.assertEqual(diagonal_split["orientation"], "backslash") self.assertEqual( frame.tiles["r1c1"].metadata["diagonal_split"]["orientation"], frame.tiles["r2c1"].metadata["diagonal_split"]["orientation"], ) self.assertEqual( frame.tiles["r1c1"].metadata["diagonal_split"]["orientation"], frame.tiles["r1c2"].metadata["diagonal_split"]["orientation"], ) led_colors = { color.to_8bit_tuple() for segment_pixels in tile.led_pixels.values() for color in segment_pixels } self.assertIn((255, 255, 255), led_colors) self.assertIn((0, 0, 0), led_colors) def test_checkerd_flips_diagonal_instead_of_shifting_colors(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, speed=1.0, step_size=1.0, checker_mode="checkerd", color_mode="dual", primary_color="#FFFFFF", secondary_color="#000000", ) frame_0 = PatternEngine().render_frame(config, "checker", params, timestamp=0.0) frame_1 = PatternEngine().render_frame(config, "checker", params, timestamp=1.01) split_0 = frame_0.tiles["r1c1"].metadata["diagonal_split"] split_1 = frame_1.tiles["r1c1"].metadata["diagonal_split"] self.assertNotEqual(split_0["orientation"], split_1["orientation"]) self.assertEqual(split_0["color_a"].to_8bit_tuple(), split_1["color_a"].to_8bit_tuple()) self.assertEqual(split_0["color_b"].to_8bit_tuple(), split_1["color_b"].to_8bit_tuple()) self.assertEqual( frame_0.tiles["r1c1"].metadata["diagonal_split"]["orientation"], frame_0.tiles["r2c1"].metadata["diagonal_split"]["orientation"], ) self.assertEqual( frame_0.tiles["r1c1"].metadata["diagonal_split"]["orientation"], frame_0.tiles["r1c2"].metadata["diagonal_split"]["orientation"], ) def test_checkerd_mono_off_half_reaches_true_black(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, checker_mode="checkerd", color_mode="mono", primary_color="#FFFFFF", secondary_color="#000000", ) frame = PatternEngine().render_frame(config, "checker", params, timestamp=0.0) led_colors = { color.to_8bit_tuple() for segment_pixels in frame.tiles["r1c1"].led_pixels.values() for color in segment_pixels } self.assertIn((0, 0, 0), led_colors) self.assertNotIn((71, 71, 71), led_colors) self.assertIn((255, 255, 255), led_colors) def test_arrow_pattern_matches_discrete_double_chevron_layout(self) -> None: config = load_config(SAMPLE_MAPPING) engine = PatternEngine() params = PatternParameters( brightness=1.0, speed=1.0, step_size=1.0, block_size=1.0, direction="left_to_right", color_mode="dual", primary_color="#FFFFFF", secondary_color="#000000", ) frame = engine.render_frame(config, "arrow", params, timestamp=0.0) active_tiles = { tile_id for tile_id, tile in frame.tiles.items() if tile.fill_color.to_8bit_tuple() == (255, 255, 255) } self.assertEqual(active_tiles, {"r1c1", "r1c4", "r2c2", "r2c5", "r3c1", "r3c4"}) def test_row_chase_advances_one_clean_row_at_a_time(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, speed=1.0, step_size=1.0, block_size=1.0, direction="top_to_bottom", color_mode="dual", primary_color="#FFFFFF", secondary_color="#000000", ) expected = [ {"r1c1", "r1c2", "r1c3", "r1c4", "r1c5", "r1c6"}, {"r2c1", "r2c2", "r2c3", "r2c4", "r2c5", "r2c6"}, {"r3c1", "r3c2", "r3c3", "r3c4", "r3c5", "r3c6"}, {"r1c1", "r1c2", "r1c3", "r1c4", "r1c5", "r1c6"}, ] for timestamp, active_row in zip((0.0, 1.01, 2.01, 3.01), expected): frame = PatternEngine().render_frame(config, "row_chase", params, timestamp=timestamp) active_tiles = { tile_id for tile_id, tile in frame.tiles.items() if tile.fill_color.to_8bit_tuple() != (0, 0, 0) } self.assertEqual(active_tiles, active_row) def test_wave_line_matches_requested_3x3_diagonal_cycle(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, speed=1.0, step_size=1.0, direction="left_to_right", color_mode="dual", primary_color="#FFFFFF", secondary_color="#000000", ) expected_frames = [ {"r1c1", "r1c5", "r2c2", "r2c4", "r2c6", "r3c3"}, {"r1c2", "r1c6", "r2c1", "r2c3", "r2c5", "r3c4"}, {"r1c3", "r2c2", "r2c4", "r2c6", "r3c1", "r3c5"}, {"r1c4", "r2c1", "r2c3", "r2c5", "r3c2", "r3c6"}, ] for timestamp, expected_active in zip((0.0, 1.01, 2.01, 3.01), expected_frames): frame = PatternEngine().render_frame(config, "wave_line", params, timestamp=timestamp) active = { tile_id for tile_id, tile in frame.tiles.items() if tile.fill_color.to_8bit_tuple() == (255, 255, 255) } self.assertEqual(active, expected_active) self.assertEqual(len(active), 6) active_rows = {int(tile_id[1]) for tile_id in active} self.assertEqual(active_rows, {1, 2, 3}) def test_wave_line_mono_inactive_tiles_can_fall_fully_black(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, direction="left_to_right", color_mode="mono", primary_color="#FFFFFF", secondary_color="#000000", ) frame = PatternEngine().render_frame(config, "wave_line", params, timestamp=0.0) self.assertEqual(frame.tiles["r1c2"].fill_color.to_8bit_tuple(), (0, 0, 0)) def test_diagonal_scan_uses_triangle_half_steps(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, speed=1.0, step_size=1.0, direction="left_to_right", scan_style="line", color_mode="mono", primary_color="#FFFFFF", secondary_color="#000000", ) expected_frames = [ {("r3c1", "b")}, {("r3c1", "a")}, {("r2c1", "b"), ("r3c2", "b")}, {("r2c1", "a"), ("r3c2", "a")}, ] for timestamp, expected_active in zip((0.0, 1.01, 2.01, 3.01), expected_frames): frame = PatternEngine().render_frame(config, "diagonal_scan", params, timestamp=timestamp) active_halves: set[tuple[str, str]] = set() for tile_id, tile in frame.tiles.items(): split = tile.metadata.get("diagonal_split") self.assertIsInstance(split, dict) self.assertEqual(split["orientation"], "backslash") if split["color_a"].to_8bit_tuple() != (0, 0, 0): active_halves.add((tile_id, "a")) if split["color_b"].to_8bit_tuple() != (0, 0, 0): active_halves.add((tile_id, "b")) self.assertEqual(active_halves, expected_active) def test_diagonal_scan_bands_fill_the_wall_with_moving_diagonal_stripes(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, speed=1.0, step_size=1.0, direction="left_to_right", scan_style="bands", color_mode="mono", primary_color="#FFFFFF", secondary_color="#000000", ) expected_frames = [ ("baxb.a", "xbaxb.", "xx.axb"), ("xbaxb.", "axbaxb", ".axbax"), ("axbaxb", ".axbax", "b.axba"), (".axbax", "b.axba", "xb.axb"), ] for timestamp, expected_rows in zip((0.0, 1.01, 2.01, 3.01), expected_frames): frame = PatternEngine().render_frame(config, "diagonal_scan", params, timestamp=timestamp) rows: list[str] = [] for row in range(1, 4): chars: list[str] = [] for col in range(1, 7): split = frame.tiles[f"r{row}c{col}"].metadata["diagonal_split"] active_a = split["color_a"].to_8bit_tuple() != (0, 0, 0) active_b = split["color_b"].to_8bit_tuple() != (0, 0, 0) chars.append("x" if active_a and active_b else "a" if active_a else "b" if active_b else ".") rows.append("".join(chars)) self.assertEqual(tuple(rows), expected_rows) def test_diagonal_scan_bands_keep_parallel_stripes_in_vertical_mode(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, speed=1.0, step_size=1.0, direction="top_to_bottom", scan_style="bands", color_mode="mono", primary_color="#FFFFFF", secondary_color="#000000", ) expected_frames = [ ("baxb.a", "xbaxb.", "xx.axb"), ("xbaxb.", "axbaxb", ".axbax"), ("axbaxb", ".axbax", "b.axba"), ] for timestamp, expected_rows in zip((0.0, 1.01, 2.01), expected_frames): frame = PatternEngine().render_frame(config, "diagonal_scan", params, timestamp=timestamp) rows: list[str] = [] for row in range(1, 4): chars: list[str] = [] for col in range(1, 7): split = frame.tiles[f"r{row}c{col}"].metadata["diagonal_split"] self.assertEqual(split["orientation"], "backslash") active_a = split["color_a"].to_8bit_tuple() != (0, 0, 0) active_b = split["color_b"].to_8bit_tuple() != (0, 0, 0) chars.append("x" if active_a and active_b else "a" if active_a else "b" if active_b else ".") rows.append("".join(chars)) self.assertEqual(tuple(rows), expected_rows) def test_scan_225_degree_line_crosses_the_wall_instead_of_sticking_to_one_corner(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, speed=1.0, step_size=1.0, angle=225.0, scan_style="line", on_width=1.0, off_width=1.0, color_mode="mono", primary_color="#FFFFFF", secondary_color="#000000", ) frame = PatternEngine().render_frame(config, "scan", params, timestamp=5.01) active = { tile_id for tile_id, tile in frame.tiles.items() if tile.fill_color.to_8bit_tuple() != (0, 0, 0) } self.assertGreaterEqual(len(active), 5) self.assertEqual({tile_id[1] for tile_id in active}, {"1", "2", "3"}) self.assertIn("r1c3", active) self.assertIn("r3c1", active) def test_scan_dual_direction_changes_which_side_is_leading(self) -> None: config = load_config(SAMPLE_MAPPING) left_to_right = PatternParameters( brightness=1.0, direction="left_to_right", color_mode="mono", primary_color="#FFFFFF", secondary_color="#000000", ) right_to_left = PatternParameters( brightness=1.0, direction="right_to_left", color_mode="mono", primary_color="#FFFFFF", secondary_color="#000000", ) frame_ltr = PatternEngine().render_frame(config, "scan_dual", left_to_right, timestamp=1.23) frame_rtl = PatternEngine().render_frame(config, "scan_dual", right_to_left, timestamp=1.23) ltr_profile = [frame_ltr.tiles[f"r1c{col}"].fill_color.to_8bit_tuple()[0] for col in range(1, 7)] rtl_profile = [frame_rtl.tiles[f"r1c{col}"].fill_color.to_8bit_tuple()[0] for col in range(1, 7)] self.assertEqual(ltr_profile, list(reversed(rtl_profile))) self.assertNotEqual(ltr_profile, rtl_profile) def test_saw_mono_can_drop_fully_to_black(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, direction="right_to_left", color_mode="mono", primary_color="#FFAA00", secondary_color="#000000", ) frame = PatternEngine().render_frame(config, "saw", params, timestamp=0.0) fills = {tile.fill_color.to_8bit_tuple() for tile in frame.tiles.values()} self.assertIn((0, 0, 0), fills) self.assertTrue(any(color != (0, 0, 0) for color in fills)) def test_center_pulse_expands_from_middle_outward(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, speed=1.0, color_mode="mono", primary_color="#FFFFFF", secondary_color="#000000", ) frame_center = PatternEngine().render_frame(config, "center_pulse", params, timestamp=0.0) active_center = { tile_id for tile_id, tile in frame_center.tiles.items() if max(tile.fill_color.to_8bit_tuple()) >= 248 } self.assertEqual(active_center, {"r2c3", "r2c4"}) frame_ring = PatternEngine().render_frame(config, "center_pulse", params, timestamp=0.76) active_ring = { tile_id for tile_id, tile in frame_ring.tiles.items() if max(tile.fill_color.to_8bit_tuple()) >= 248 } self.assertEqual(active_ring, {"r1c3", "r1c4", "r2c2", "r2c5", "r3c3", "r3c4"}) frame_outer = PatternEngine().render_frame(config, "center_pulse", params, timestamp=1.52) active_outer = { tile_id for tile_id, tile in frame_outer.tiles.items() if max(tile.fill_color.to_8bit_tuple()) >= 248 } self.assertEqual(active_outer, {"r1c2", "r1c5", "r2c1", "r2c6", "r3c2", "r3c5"}) def test_center_pulse_reverse_starts_at_outermost_ring(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, color_mode="mono", primary_color="#FFFFFF", secondary_color="#000000", center_pulse_mode="reverse", ) frame = PatternEngine().render_frame(config, "center_pulse", params, timestamp=0.0) active_tiles = { tile_id for tile_id, tile in frame.tiles.items() if max(tile.fill_color.to_8bit_tuple()) >= 248 } self.assertEqual(active_tiles, {"r1c1", "r1c6", "r3c1", "r3c6"}) def test_center_pulse_dual_mode_inactive_tiles_can_fall_to_true_black(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, color_mode="dual", primary_color="#FF00FF", secondary_color="#220044", ) frame = PatternEngine().render_frame(config, "center_pulse", params, timestamp=0.0) self.assertEqual(frame.tiles["r1c1"].fill_color.to_8bit_tuple(), (0, 0, 0)) self.assertTrue(all(color.to_8bit_tuple() == (0, 0, 0) for color in frame.tiles["r1c1"].led_pixels["1 - 81 LED 27"])) def test_center_pulse_outline_only_uses_inner_rectangle_segments_first(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, color_mode="mono", primary_color="#FFFFFF", secondary_color="#000000", center_pulse_mode="outline", ) frame = PatternEngine().render_frame(config, "center_pulse", params, timestamp=0.0) active_tiles = { tile_id for tile_id, tile in frame.tiles.items() if any(color.to_8bit_tuple() != (0, 0, 0) for segment in tile.led_pixels.values() for color in segment) } center_tile = frame.tiles["r2c3"] top = {color.to_8bit_tuple() for color in center_tile.led_pixels["244 - 318 LED 25"]} bottom = {color.to_8bit_tuple() for color in center_tile.led_pixels["82 - 162 LED 27"]} left = {color.to_8bit_tuple() for color in center_tile.led_pixels["1 - 81 LED 27"]} right = {color.to_8bit_tuple() for color in center_tile.led_pixels["163 - 243 LED 27"]} self.assertEqual(active_tiles, {"r2c2", "r2c3", "r2c4", "r2c5"}) self.assertEqual(top, {(255, 255, 255)}) self.assertEqual(bottom, {(255, 255, 255)}) self.assertEqual(left, {(0, 0, 0)}) self.assertEqual(right, {(0, 0, 0)}) def test_center_pulse_outline_reverse_starts_with_outer_wall_perimeter(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, color_mode="mono", primary_color="#FFFFFF", secondary_color="#000000", center_pulse_mode="outline_reverse", ) frame = PatternEngine().render_frame(config, "center_pulse", params, timestamp=0.0) active_tiles = { tile_id for tile_id, tile in frame.tiles.items() if any(color.to_8bit_tuple() != (0, 0, 0) for segment in tile.led_pixels.values() for color in segment) } self.assertEqual( active_tiles, { "r1c1", "r1c2", "r1c3", "r1c4", "r1c5", "r1c6", "r2c1", "r2c6", "r3c1", "r3c2", "r3c3", "r3c4", "r3c5", "r3c6", }, ) def test_strobe_random_pixels_reaches_single_leds_inside_one_tile(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, speed=1.0, strobe_mode="random_pixels", strobe_duty_cycle=0.5, color_mode="mono", primary_color="#0000FF", secondary_color="#FF0000", ) frame = PatternEngine().render_frame(config, "strobe", params, timestamp=0.0) first_tile = frame.tiles["r1c1"] led_colors = { color.to_8bit_tuple() for segment_pixels in first_tile.led_pixels.values() for color in segment_pixels } self.assertIn((0, 0, 0), led_colors) self.assertIn((0, 0, 255), led_colors) def test_strobe_random_pixels_can_group_multiple_leds_into_one_block(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, strobe_mode="random_pixels", pixel_group_size=3.0, strobe_duty_cycle=0.5, color_mode="mono", primary_color="#0000FF", secondary_color="#FF0000", ) frame = PatternEngine().render_frame(config, "strobe", params, timestamp=0.0) first_segment = frame.tiles["r1c1"].led_pixels["1 - 81 LED 27"] for index in range(1, len(first_segment)): if first_segment[index].to_8bit_tuple() != first_segment[index - 1].to_8bit_tuple(): self.assertEqual(index % 3, 0) def test_strobe_random_leds_can_change_inside_a_pixel_group(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, strobe_mode="random_leds", pixel_group_size=3.0, strobe_duty_cycle=0.5, color_mode="mono", primary_color="#0000FF", secondary_color="#FF0000", ) frame = PatternEngine().render_frame(config, "strobe", params, timestamp=0.0) transition_indices = [ index for segment in frame.tiles["r1c1"].led_pixels.values() for index in range(1, len(segment)) if segment[index].to_8bit_tuple() != segment[index - 1].to_8bit_tuple() ] self.assertTrue(transition_indices) self.assertTrue(any(index % 3 != 0 for index in transition_indices)) def test_strobe_random_pixels_density_follows_duty_cycle(self) -> None: config = load_config(SAMPLE_MAPPING) low = PatternParameters( brightness=1.0, strobe_mode="random_pixels", strobe_duty_cycle=0.08, color_mode="mono", primary_color="#0000FF", secondary_color="#FF0000", ) high = PatternParameters( brightness=1.0, strobe_mode="random_pixels", strobe_duty_cycle=0.9, color_mode="mono", primary_color="#0000FF", secondary_color="#FF0000", ) low_frame = PatternEngine().render_frame(config, "strobe", low, timestamp=0.0) high_frame = PatternEngine().render_frame(config, "strobe", high, timestamp=0.0) low_lit = sum( 1 for segment in low_frame.tiles["r1c1"].led_pixels.values() for color in segment if color.to_8bit_tuple() != (0, 0, 0) ) high_lit = sum( 1 for segment in high_frame.tiles["r1c1"].led_pixels.values() for color in segment if color.to_8bit_tuple() != (0, 0, 0) ) self.assertGreater(high_lit, low_lit) def test_strobe_global_returns_directly_to_black_even_with_smoothing_enabled(self) -> None: config = load_config(SAMPLE_MAPPING) engine = PatternEngine() params = PatternParameters( brightness=1.0, fade=0.35, strobe_mode="global", strobe_duty_cycle=0.5, color_mode="mono", primary_color="#FFFFFF", secondary_color="#000000", ) on_frame = engine.render_frame(config, "strobe", params, timestamp=0.0, tempo_bpm=120.0) off_frame = engine.render_frame(config, "strobe", params, timestamp=0.07, tempo_bpm=120.0) self.assertEqual(on_frame.tiles["r1c1"].fill_color.to_8bit_tuple(), (255, 255, 255)) self.assertEqual(off_frame.tiles["r1c1"].fill_color.to_8bit_tuple(), (0, 0, 0)) self.assertTrue( all( color.to_8bit_tuple() == (0, 0, 0) for segment in off_frame.tiles["r1c1"].led_pixels.values() for color in segment ) ) def test_sparkle_random_colors_changes_hue_between_buckets(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, speed=1.0, strobe_duty_cycle=0.72, color_mode="random_colors", primary_color="#0000FF", secondary_color="#FF0000", ) pattern = PatternEngine().registry.get("sparkle") samples_a = pattern.render(PatternContext(config=config, params=params, time_s=0.0, tempo_bpm=60.0, tempo_phase=0.0)) samples_b = pattern.render(PatternContext(config=config, params=params, time_s=1.01, tempo_bpm=60.0, tempo_phase=1.01)) colors_a = {sample.fill_color.to_8bit_tuple() for sample in samples_a.values() if sample.fill_color.to_8bit_tuple() != (0, 0, 0)} colors_b = {sample.fill_color.to_8bit_tuple() for sample in samples_b.values() if sample.fill_color.to_8bit_tuple() != (0, 0, 0)} self.assertTrue(colors_a) self.assertTrue(colors_b) self.assertTrue(all(_matches_scaled_random_effect_color(color) for color in colors_a)) self.assertTrue(all(_matches_scaled_random_effect_color(color) for color in colors_b)) self.assertNotEqual(colors_a, colors_b) def test_sparkle_density_can_fall_back_to_black(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, strobe_duty_cycle=0.08, color_mode="mono", primary_color="#00FF00", secondary_color="#000000", ) frame = PatternEngine().render_frame(config, "sparkle", params, timestamp=0.0) fills = {tile.fill_color.to_8bit_tuple() for tile in frame.tiles.values()} self.assertIn((0, 0, 0), fills) self.assertTrue(any(color != (0, 0, 0) for color in fills)) def test_random_colors_break_up_same_axis_tiles(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, color_mode="random_colors", primary_color="#0000FF", secondary_color="#FF0000", ) frame = PatternEngine().render_frame(config, "column_gradient", params, timestamp=0.0) column_two = { frame.tiles[f"r{row}c2"].fill_color.to_8bit_tuple() for row in range(1, 4) } unique_fills = {tile.fill_color.to_8bit_tuple() for tile in frame.tiles.values()} self.assertGreater(len(column_two), 1) self.assertGreater(len(unique_fills), 6) def test_custom_random_colors_follow_user_selected_color_family(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, color_mode="custom_random", primary_color="#FF0000", secondary_color="#00FF00", ) pattern = PatternEngine().registry.get("sparkle") samples = pattern.render(PatternContext(config=config, params=params, time_s=0.0, tempo_bpm=60.0, tempo_phase=0.0)) visible_colors = {sample.fill_color.to_8bit_tuple() for sample in samples.values() if sample.fill_color.to_8bit_tuple() != (0, 0, 0)} allowed_colors = {color.to_8bit_tuple() for color in custom_random_color_choices("#FF0000", "#00FF00")} self.assertTrue(visible_colors) self.assertTrue(all(_matches_scaled_choice_color(color, allowed_colors) for color in visible_colors)) self.assertGreaterEqual(len(visible_colors), 2) def test_legacy_palette_names_map_to_new_event_palettes(self) -> None: config = load_config(SAMPLE_MAPPING) legacy = PatternParameters( brightness=1.0, color_mode="palette", palette="Steel Bloom", ) canonical = PatternParameters( brightness=1.0, color_mode="palette", palette="Magenta Drive", ) legacy_frame = PatternEngine().render_frame(config, "solid", legacy, timestamp=0.0) canonical_frame = PatternEngine().render_frame(config, "solid", canonical, timestamp=0.0) self.assertEqual( legacy_frame.tiles["r1c1"].fill_color.to_8bit_tuple(), canonical_frame.tiles["r1c1"].fill_color.to_8bit_tuple(), ) def test_stopwatch_sync_counts_leds_up_and_back_down(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, speed=1.0, step_size=1.0, stopwatch_mode="sync", color_mode="mono", primary_color="#00FF00", secondary_color="#000000", ) expected_counts = { 0.0: 1, 0.06: 2, 0.11: 3, 5.31: 106, 5.36: 105, } for timestamp, expected in expected_counts.items(): frame = PatternEngine().render_frame(config, "stopwatch", params, timestamp=timestamp) lit_counts = { sum(1 for segment in tile.led_pixels.values() for color in segment if color.to_8bit_tuple() != (0, 0, 0)) for tile in frame.tiles.values() } self.assertEqual(lit_counts, {expected}) frame = PatternEngine().render_frame(config, "stopwatch", params, timestamp=0.0) first_tile = frame.tiles["r1c1"] segment_lit_counts = { segment_name: sum(1 for color in colors if color.to_8bit_tuple() != (0, 0, 0)) for segment_name, colors in first_tile.led_pixels.items() } self.assertEqual(segment_lit_counts["1 - 81 LED 27"], 1) self.assertEqual(segment_lit_counts["82 - 162 LED 27"], 0) self.assertEqual(segment_lit_counts["163 - 243 LED 27"], 0) self.assertEqual(segment_lit_counts["244 - 318 LED 25"], 0) def test_stopwatch_does_not_skip_full_strip_at_low_preview_fps(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, tempo_multiplier=8.0, stopwatch_mode="sync", color_mode="mono", primary_color="#00FF00", secondary_color="#000000", ) engine = PatternEngine() counts = [] for index in range(40): frame = engine.render_frame(config, "stopwatch", params, timestamp=index / 23.0, tempo_bpm=120.0) counts.append( sum( 1 for segment in frame.tiles["r1c1"].led_pixels.values() for color in segment if color.to_8bit_tuple() != (0, 0, 0) ) ) self.assertIn(106, counts) def test_stopwatch_keeps_counterclockwise_channel_order(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, speed=1.0, step_size=1.0, stopwatch_mode="sync", color_mode="mono", primary_color="#00FF00", secondary_color="#000000", ) frame_bottom = PatternEngine().render_frame(config, "stopwatch", params, timestamp=1.81) bottom = frame_bottom.tiles["r1c1"].led_pixels["82 - 162 LED 27"] bottom_lit = [index for index, color in enumerate(bottom) if color.to_8bit_tuple() != (0, 0, 0)] self.assertEqual(bottom_lit, list(range(10))) frame_right = PatternEngine().render_frame(config, "stopwatch", params, timestamp=2.81) right = frame_right.tiles["r1c1"].led_pixels["163 - 243 LED 27"] right_lit = [index for index, color in enumerate(right) if color.to_8bit_tuple() != (0, 0, 0)] self.assertEqual(right_lit, list(range(3))) def test_stopwatch_tempo_multiplier_speeds_up_fill(self) -> None: config = load_config(SAMPLE_MAPPING) slow = PatternParameters( brightness=1.0, tempo_multiplier=1.0, stopwatch_mode="sync", color_mode="mono", primary_color="#00FF00", secondary_color="#000000", ) fast = PatternParameters( brightness=1.0, tempo_multiplier=4.0, stopwatch_mode="sync", color_mode="mono", primary_color="#00FF00", secondary_color="#000000", ) slow_frame = PatternEngine().render_frame(config, "stopwatch", slow, timestamp=1.0) fast_frame = PatternEngine().render_frame(config, "stopwatch", fast, timestamp=1.0) slow_count = sum( 1 for segment in slow_frame.tiles["r1c1"].led_pixels.values() for color in segment if color.to_8bit_tuple() != (0, 0, 0) ) fast_count = sum( 1 for segment in fast_frame.tiles["r1c1"].led_pixels.values() for color in segment if color.to_8bit_tuple() != (0, 0, 0) ) self.assertGreater(fast_count, slow_count) def test_stopwatch_random_mode_offsets_tiles_and_changes_cycle_colors(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, speed=1.0, step_size=1.0, stopwatch_mode="random", color_mode="random_colors", primary_color="#00FF00", secondary_color="#000000", ) frame_a = PatternEngine().render_frame(config, "stopwatch", params, timestamp=0.0) frame_b = PatternEngine().render_frame(config, "stopwatch", params, timestamp=212.01) counts = [ sum(1 for segment in tile.led_pixels.values() for color in segment if color.to_8bit_tuple() != (0, 0, 0)) for tile in frame_a.tiles.values() ] self.assertGreater(len(set(counts)), 1) colors_a = [ next((color.to_8bit_tuple() for segment in tile.led_pixels.values() for color in segment if color.to_8bit_tuple() != (0, 0, 0)), None) for tile in frame_a.tiles.values() ] colors_b = [ next((color.to_8bit_tuple() for segment in tile.led_pixels.values() for color in segment if color.to_8bit_tuple() != (0, 0, 0)), None) for tile in frame_b.tiles.values() ] self.assertTrue({color for color in colors_a if color is not None}.issubset(RANDOM_EFFECT_COLORS)) self.assertTrue({color for color in colors_b if color is not None}.issubset(RANDOM_EFFECT_COLORS)) self.assertNotEqual(colors_a, colors_b) def test_snake_eats_apple_keeps_length_and_respawns_new_apple(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, speed=1.0, randomness=0.0, color_mode="dual", primary_color="#0000FF", secondary_color="#FF0000", ) snake = SnakePattern() snake._shape = (config.logical_display.rows, config.logical_display.cols) snake._snake = [(0, 0), (1, 0), (2, 0), (2, 1)] snake._direction = (0, 1) snake._apple = (0, 1) snake._last_time_s = 0.0 snake._step_progress = 0.0 snake._blink_until_time = None before = snake.render(PatternContext(config=config, params=params, time_s=0.0)) self.assertEqual(before["r1c2"].fill_color.to_8bit_tuple(), (255, 0, 0)) after = snake.render(PatternContext(config=config, params=params, time_s=0.46)) self.assertEqual(snake._snake[0], (0, 1)) self.assertEqual(len(snake._snake), 4) self.assertNotEqual(snake._apple, (0, 1)) self.assertIsNotNone(snake._apple) self.assertNotIn(snake._apple, snake._snake) self.assertIsNotNone(snake._blink_until_time) self.assertNotEqual(after["r1c2"].fill_color.to_8bit_tuple(), (255, 0, 0)) settled = snake.render(PatternContext(config=config, params=params, time_s=0.62)) self.assertEqual(settled["r1c2"].fill_color.to_8bit_tuple(), (0, 0, 255)) def test_snake_uses_single_body_color_and_secondary_for_apple(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, speed=1.0, randomness=0.0, color_mode="dual", primary_color="#0000FF", secondary_color="#FF0000", ) snake = SnakePattern() snake._shape = (config.logical_display.rows, config.logical_display.cols) snake._snake = [(0, 3), (0, 2), (0, 1), (0, 0)] snake._direction = (0, 1) snake._apple = (1, 1) snake._last_time_s = 0.0 snake._step_progress = 0.0 snake._blink_until_time = None samples = snake.render(PatternContext(config=config, params=params, time_s=0.0)) self.assertEqual(samples["r1c1"].fill_color.to_8bit_tuple(), (0, 0, 255)) self.assertEqual(samples["r1c2"].fill_color.to_8bit_tuple(), (0, 0, 255)) self.assertEqual(samples["r2c2"].fill_color.to_8bit_tuple(), (255, 0, 0)) def test_snake_prefers_forward_motion_until_it_has_to_turn(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, speed=1.0, randomness=0.0, color_mode="dual", primary_color="#0000FF", secondary_color="#FF0000", ) snake = SnakePattern() snake._shape = (config.logical_display.rows, config.logical_display.cols) snake._snake = [(1, 2), (1, 1), (1, 0), (0, 0)] snake._direction = (0, 1) snake._apple = (1, 5) snake._last_time_s = 0.0 snake._step_progress = 0.0 snake._blink_until_time = None snake.render(PatternContext(config=config, params=params, time_s=0.46)) self.assertEqual(snake._snake[0], (1, 3)) self.assertEqual(snake._direction, (0, 1)) def test_snake_tempo_changes_step_rate(self) -> None: config = load_config(SAMPLE_MAPPING) low_params = PatternParameters( brightness=1.0, randomness=0.0, color_mode="dual", primary_color="#0000FF", secondary_color="#FF0000", ) high_params = PatternParameters( brightness=1.0, randomness=0.0, color_mode="dual", primary_color="#0000FF", secondary_color="#FF0000", ) low_snake = SnakePattern() high_snake = SnakePattern() for snake in (low_snake, high_snake): snake._shape = (config.logical_display.rows, config.logical_display.cols) snake._snake = [(1, 2), (1, 1), (1, 0), (0, 0)] snake._direction = (0, 1) snake._apple = (1, 5) snake._last_time_s = 0.0 snake._step_progress = 0.0 snake._blink_until_time = None low_snake.render(PatternContext(config=config, params=low_params, time_s=0.35, tempo_bpm=12.0)) high_snake.render(PatternContext(config=config, params=high_params, time_s=0.35, tempo_bpm=240.0)) self.assertEqual(low_snake._snake[0], (1, 2)) self.assertEqual(high_snake._snake[0], (1, 5)) def test_snake_does_not_catch_up_after_long_idle_gap(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, speed=4.0, randomness=0.0, color_mode="dual", primary_color="#0000FF", secondary_color="#FF0000", ) snake = SnakePattern() snake._shape = (config.logical_display.rows, config.logical_display.cols) snake._snake = [(1, 2), (1, 1), (1, 0), (0, 0)] snake._direction = (0, 1) snake._apple = (1, 5) snake._last_time_s = 0.0 snake._step_progress = 0.0 snake._blink_until_time = None snake.render(PatternContext(config=config, params=params, time_s=2.0)) self.assertEqual(snake._snake[0], (1, 2)) self.assertEqual(snake._direction, (0, 1)) def test_snake_free_run_eventually_reaches_apple(self) -> None: config = load_config(SAMPLE_MAPPING) params = PatternParameters( brightness=1.0, speed=1.0, randomness=0.35, color_mode="dual", primary_color="#4D7CFF", secondary_color="#FF0000", ) snake = SnakePattern() ate_apple = False previous_length = None for index in range(20): snake.render(PatternContext(config=config, params=params, time_s=index * 0.46)) if previous_length is None: previous_length = len(snake._snake) if snake._blink_until_time is not None: ate_apple = True break self.assertTrue(ate_apple) self.assertEqual(len(snake._snake), previous_length) if __name__ == "__main__": unittest.main()