Files
ledgrab/server/tests/core/test_calibration_solver.py
T
alexei.dolgolyov 0409cd8b66 feat(calibration): auto edge-calibration backend core (phase 1)
Backend engine for guided LED-chase calibration, driven by the upcoming
auto-calibration UI (phase 3) and first-run wizard (phase 4).

- solve_calibration(): pure function mapping start corner + direction + 4
  corner-tap indices to per-edge LED counts, consistent with EDGE_ORDER/
  EDGE_REVERSE so it round-trips through build_segments().
- CalibrationChaseMixin.set_calibration_pixel(): light a specific LED index
  (+ optional window) on a device, reusing the device_test_mode idle-client
  send path.
- CalibrationSession: single-active session with start/position/stop/cancel,
  a 60s idle-timeout watchdog, and a concurrency lock so interleaved calls
  can't corrupt the stop/restore bookkeeping — start() stops + remembers any
  running target on the device and stop/cancel/timeout always restore it
  (never leaves the device dark or stuck in chase).
- Routes /api/v1/calibration/{session,session/position,session/stop,
  session/cancel,session/state,solve} (all AuthRequired, bounds-validated);
  calibration is persisted by reusing the existing PUT /color-strip-sources/
  {id} (hot-reloads running streams) rather than a duplicate endpoint.
- Tests: 19 solver pure-logic + 19 route/bounds. docs/API.md updated.

Part of the edge-calibration + first-run-wizard feature (Big Bang; intermediate
phase — full build/suite gated at the final phase).
2026-06-08 14:59:58 +03:00

316 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"