Files
RFP_Infinity-Vis/tests/test_pattern_engine.py

1169 lines
46 KiB
Python

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