"""Unit tests for solve_calibration() — pure logic, runs in isolation. Tests cover: - All 8 (start_position × layout) combinations - 0-LED edge (two corners tapped adjacent) - offset pass-through - Round-trip through build_segments() - Wrap-around (corner_indices straddle the 0/led_count boundary) """ import pytest from ledgrab.core.capture.calibration import ( EDGE_ORDER, CalibrationConfig, solve_calibration, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _assert_roundtrip(cfg: CalibrationConfig) -> None: """build_segments() must not crash and must cover the expected LED count.""" segs = cfg.build_segments() total_from_segs = sum(s.led_count for s in segs) expected = cfg.leds_top + cfg.leds_right + cfg.leds_bottom + cfg.leds_left assert total_from_segs == expected, ( f"Segment total {total_from_segs} != field total {expected} " f"for cfg={cfg!r}" ) def _edge_counts(cfg: CalibrationConfig) -> dict[str, int]: return { "top": cfg.leds_top, "right": cfg.leds_right, "bottom": cfg.leds_bottom, "left": cfg.leds_left, } # --------------------------------------------------------------------------- # Basic: bottom_left / clockwise (canonical case) # --------------------------------------------------------------------------- class TestBottomLeftClockwise: """start_position=bottom_left, layout=clockwise. EDGE_ORDER: ["left", "top", "right", "bottom"] Strip walk: LED 0 is at bottom-left corner, goes UP the left edge, across the top, DOWN the right, and back along the bottom. Corner indices for a 100-LED, 20/30/20/30 (L/T/R/B) layout: bottom_left -> 0 top_left -> 20 (after left edge) top_right -> 50 (after top edge) bottom_right -> 70 (after right edge) """ START = "bottom_left" LAYOUT = "clockwise" LED_COUNT = 100 def _make_corner_indices(self) -> list[int]: # left=20, top=30, right=20, bottom=30 return [0, 20, 50, 70] # BL, TL, TR, BR def test_basic_counts(self): cfg = solve_calibration( led_count=self.LED_COUNT, start_position=self.START, layout=self.LAYOUT, corner_indices=self._make_corner_indices(), ) counts = _edge_counts(cfg) assert counts["left"] == 20 assert counts["top"] == 30 assert counts["right"] == 20 assert counts["bottom"] == 30 def test_start_position_preserved(self): cfg = solve_calibration( led_count=self.LED_COUNT, start_position=self.START, layout=self.LAYOUT, corner_indices=self._make_corner_indices(), ) assert cfg.start_position == self.START def test_layout_preserved(self): cfg = solve_calibration( led_count=self.LED_COUNT, start_position=self.START, layout=self.LAYOUT, corner_indices=self._make_corner_indices(), ) assert cfg.layout == self.LAYOUT def test_roundtrip(self): cfg = solve_calibration( led_count=self.LED_COUNT, start_position=self.START, layout=self.LAYOUT, corner_indices=self._make_corner_indices(), ) _assert_roundtrip(cfg) def test_offset_passthrough(self): cfg = solve_calibration( led_count=self.LED_COUNT, start_position=self.START, layout=self.LAYOUT, corner_indices=self._make_corner_indices(), offset=5, ) assert cfg.offset == 5 _assert_roundtrip(cfg) # --------------------------------------------------------------------------- # All 8 combinations: smoke test (round-trip + total == led_count) # --------------------------------------------------------------------------- ALL_CORNERS: dict[str, list[str]] = { # start_position: [BL, TL, TR, BR] corners in the order they appear on the strip # for layout=clockwise. We use 100 LEDs with 25 per edge for simplicity. "bottom_left": ["BL", "TL", "TR", "BR"], "top_left": ["TL", "TR", "BR", "BL"], "top_right": ["TR", "BR", "BL", "TL"], "bottom_right": ["BR", "BL", "TL", "TR"], } # For each start_position × layout, what are the 4 corner indices # when all edges have 25 LEDs (100 total)? # EDGE_ORDER for (start, "clockwise") gives the edge walk sequence. # We map corner names to indices by placing them at the boundaries. def _corner_indices_25_each(start_position: str, layout: str) -> list[int]: """ Build corner indices assuming all 4 edges have exactly 25 LEDs. Returns [start_corner, second_corner, third_corner, fourth_corner] following the strip walk order defined by EDGE_ORDER. The corners of the screen are: top_left=TL, top_right=TR, bottom_left=BL, bottom_right=BR Each edge start-corner is at the leading edge index; its end-corner is at that index + led_count of that edge (mod 100). """ key = (start_position, layout) order = EDGE_ORDER[key] # e.g. ["left","top","right","bottom"] # Map edge names to their start and end screen corners # Corner positions: start corner of each edge in strip order result = [] led_pos = 0 for edge in order: result.append(led_pos) led_pos += 25 return result @pytest.mark.parametrize("start_position", list(EDGE_ORDER)) def test_all_combinations_roundtrip_25_each(start_position): """All 8 (start, layout) combos with 25 LEDs/edge must round-trip.""" start_pos_str, layout = start_position # unpack tuple key indices = _corner_indices_25_each(start_pos_str, layout) cfg = solve_calibration( led_count=100, start_position=start_pos_str, layout=layout, corner_indices=indices, ) counts = _edge_counts(cfg) assert ( sum(counts.values()) == 100 ), f"{start_pos_str}/{layout}: total LEDs {sum(counts.values())} != 100" assert all( v == 25 for v in counts.values() ), f"{start_pos_str}/{layout}: edge counts {counts} not all 25" _assert_roundtrip(cfg) # --------------------------------------------------------------------------- # 0-LED edge: two corners tapped adjacent (one edge has 0 LEDs) # --------------------------------------------------------------------------- class TestZeroLedEdge: """When two consecutive corner taps are the same index, that edge has 0 LEDs.""" def test_zero_bottom_edge(self): """ bottom_left / clockwise, 100 LEDs. EDGE_ORDER: left, top, right, bottom Tap top-left and bottom-right at the same index → bottom edge = 0 We place BL=0, TL=40, TR=70, BR=70 (top=30, right=0 would be wrong; let's use BL=0, TL=25, TR=65, BR=90 for bottom=10, then make left=right=40) Actually: make right edge 0: BL=0, TL=40, TR=60, BR=60 """ # EDGE_ORDER for bottom_left/clockwise: ["left","top","right","bottom"] # Strip indices: left 0..39 (40 LEDs), top 40..59 (20 LEDs), right 60..59 (0 LEDs!), bottom 60..99 (40 LEDs) cfg = solve_calibration( led_count=100, start_position="bottom_left", layout="clockwise", corner_indices=[0, 40, 60, 60], # BL, TL, TR, BR — right=0 ) counts = _edge_counts(cfg) assert counts["left"] == 40 assert counts["top"] == 20 assert counts["right"] == 0 assert counts["bottom"] == 40 assert sum(counts.values()) == 100 _assert_roundtrip(cfg) def test_zero_first_edge(self): """First edge (left) can also be 0 if corners 0 and 1 are the same.""" # EDGE_ORDER bottom_left/clockwise: ["left","top","right","bottom"] # If BL==TL, left edge has 0 LEDs cfg = solve_calibration( led_count=60, start_position="bottom_left", layout="clockwise", corner_indices=[0, 0, 20, 40], # BL=TL, left=0 ) counts = _edge_counts(cfg) assert counts["left"] == 0 assert counts["top"] == 20 assert counts["right"] == 20 assert counts["bottom"] == 20 assert sum(counts.values()) == 60 _assert_roundtrip(cfg) # --------------------------------------------------------------------------- # Wrap-around: last corner index < first (straddles the 0 boundary) # --------------------------------------------------------------------------- class TestWrapAround: """When the strip wraps: the last segment spans from some index to led_count, then continues from 0 to the start corner. This can happen if the user provides indices that wrap around the physical end of the strip. """ def test_wrap_around_bottom_edge(self): """ bottom_left / clockwise, 100 LEDs. EDGE_ORDER: left, top, right, bottom. If the user taps: BL=80, TL=10, TR=40, BR=60 (wraps) -> left: 80..10 = (100-80)+10 = 30 -> top: 10..40 = 30 -> right:40..60 = 20 -> bottom:60..80 = 20 """ cfg = solve_calibration( led_count=100, start_position="bottom_left", layout="clockwise", corner_indices=[80, 10, 40, 60], ) counts = _edge_counts(cfg) assert counts["left"] == 30 assert counts["top"] == 30 assert counts["right"] == 20 assert counts["bottom"] == 20 assert sum(counts.values()) == 100 _assert_roundtrip(cfg) # --------------------------------------------------------------------------- # Offset # --------------------------------------------------------------------------- class TestOffset: def test_offset_stored_correctly(self): cfg = solve_calibration( led_count=100, start_position="top_left", layout="clockwise", corner_indices=[0, 25, 50, 75], offset=10, ) assert cfg.offset == 10 _assert_roundtrip(cfg) def test_offset_default_zero(self): cfg = solve_calibration( led_count=100, start_position="top_left", layout="clockwise", corner_indices=[0, 25, 50, 75], ) assert cfg.offset == 0 # --------------------------------------------------------------------------- # Mode is always "simple" # --------------------------------------------------------------------------- def test_solve_returns_simple_mode(): cfg = solve_calibration( led_count=80, start_position="top_right", layout="counterclockwise", corner_indices=[0, 20, 40, 60], ) assert cfg.mode == "simple"