// Unit tests for XYMap basic functionality and LUT vs user-function compatibility #include "test.h" #include "FastLED.h" using namespace fl; namespace { // Helper to fill a LUT for serpentine layout void fill_serpentine_lut(u16* out, u16 width, u16 height) { for (u16 y = 0; y < height; ++y) { for (u16 x = 0; x < width; ++x) { const u16 idx = y * width + x; out[idx] = xy_serpentine(x, y, width, height); } } } // Helper to fill a custom irregular LUT (not serpentine or linear) to simulate // non-standard layouts users often provide. void fill_custom_irregular_lut(u16* out, u16 width, u16 height) { // Simple reversible scramble: reverse X on even rows, reverse Y on odd rows // then map to line-by-line index. Deterministic and non-trivial. for (u16 y = 0; y < height; ++y) { for (u16 x = 0; x < width; ++x) { const u16 idx = y * width + x; const u16 xx = (y % 2 == 0) ? (u16)(width - 1 - x) : x; const u16 yy = (y % 2 == 1) ? (u16)(height - 1 - y) : y; out[idx] = (u16)(yy * width + xx); } } } } // namespace TEST_CASE("XYMap - LUT and wrapped user function mappings are identical (serpentine)") { constexpr u16 W = 5; constexpr u16 H = 4; u16 lut[W * H]; fill_serpentine_lut(lut, W, H); // Construct directly from LUT XYMap mapFromLut = XYMap::constructWithLookUpTable(W, H, lut); // Wrap via a pure formula user function (no external state) auto xy_from_serpentine_formula = +[](u16 x, u16 y, u16 width, u16 height) -> u16 { return xy_serpentine(x, y, width, height); }; XYMap mapFromWrapped = XYMap::constructWithUserFunction(W, H, xy_from_serpentine_formula); // Validate indices match for all in-bounds coordinates for (u16 y = 0; y < H; ++y) { for (u16 x = 0; x < W; ++x) { CHECK_EQ(mapFromLut.mapToIndex(x, y), mapFromWrapped.mapToIndex(x, y)); } } // Also validate that applying the same positive offset keeps them identical constexpr u16 OFFSET = 7; XYMap mapFromLutOffset = XYMap::constructWithLookUpTable(W, H, lut, OFFSET); XYMap mapFromWrappedOffset = XYMap::constructWithUserFunction(W, H, xy_from_serpentine_formula, OFFSET); for (u16 y = 0; y < H; ++y) { for (u16 x = 0; x < W; ++x) { CHECK_EQ(mapFromLutOffset.mapToIndex(x, y), mapFromWrappedOffset.mapToIndex(x, y)); } } } TEST_CASE("XYMap - LUT and wrapped user function mappings are identical (custom irregular)") { constexpr u16 W = 6; constexpr u16 H = 5; u16 lut[W * H]; fill_custom_irregular_lut(lut, W, H); XYMap mapFromLut = XYMap::constructWithLookUpTable(W, H, lut); auto xy_from_irregular_formula = +[](u16 x, u16 y, u16 width, u16 height) -> u16 { const u16 xx = (y % 2 == 0) ? (u16)(width - 1 - x) : x; const u16 yy = (y % 2 == 1) ? (u16)(height - 1 - y) : y; return (u16)(yy * width + xx); }; XYMap mapFromWrapped = XYMap::constructWithUserFunction(W, H, xy_from_irregular_formula); for (u16 y = 0; y < H; ++y) { for (u16 x = 0; x < W; ++x) { CHECK_EQ(mapFromLut.mapToIndex(x, y), mapFromWrapped.mapToIndex(x, y)); } } } TEST_CASE("XYMap - composing two 4x3 serpentine segments into a 4x6 matrix") { // Goal: Validate how two serpentine 4x3 segments (offsets 0 and 12) compose // into a 4x6 matrix, and whether they match a single 4x6 serpentine map. // Observation: With the built-in serpentine mapping, row parity resets per // segment (because y is reduced modulo height internally), which breaks // continuity across the segment boundary. Offset alone does not fix this. constexpr u16 W = 4; constexpr u16 H_SEG = 3; constexpr u16 H_FULL = 6; // Reference: a single 4x6 serpentine mapping XYMap fullSerp = XYMap::constructSerpentine(W, H_FULL); // Two 4x3 serpentine segments, stacked vertically, with offsets 0 and 12 XYMap segTop = XYMap::constructSerpentine(W, H_SEG, /*offset=*/0); XYMap segBottom = XYMap::constructSerpentine(W, H_SEG, /*offset=*/W * H_SEG); // Helper to compose index from the two segments using absolute (x,y) auto composedIndexSerp = [&](u16 x, u16 y) -> u16 { if (y < H_SEG) { return segTop.mapToIndex(x, y); } return segBottom.mapToIndex(x, y); }; SUBCASE("Default serpentine segments: top half matches; rows 3-5 mismatch due to parity reset") { // Rows 0..2 should match the single 4x6 map exactly for (u16 y = 0; y < H_SEG; ++y) { for (u16 x = 0; x < W; ++x) { CHECK_EQ(composedIndexSerp(x, y), fullSerp.mapToIndex(x, y)); } } // Row 3 (y=3) is expected to mismatch due to parity reset { u16 y = 3; CHECK_NE(composedIndexSerp(0, y), fullSerp.mapToIndex(0, y)); CHECK_NE(composedIndexSerp(W - 1, y), fullSerp.mapToIndex(W - 1, y)); } // Row 4 (y=4) also mismatches due to parity reset within the segment { u16 y = 4; CHECK_NE(composedIndexSerp(0, y), fullSerp.mapToIndex(0, y)); CHECK_NE(composedIndexSerp(W - 1, y), fullSerp.mapToIndex(W - 1, y)); } // Row 5 (y=5) mismatches due to parity reset { u16 y = 5; CHECK_NE(composedIndexSerp(0, y), fullSerp.mapToIndex(0, y)); CHECK_NE(composedIndexSerp(W - 1, y), fullSerp.mapToIndex(W - 1, y)); } } SUBCASE("User-function segments honoring absolute row parity match the 4x6 serpentine") { // Define a mapping that uses absolute y parity, but still indexes within the segment // height via y % height. This preserves boustrophedon continuity across segments. auto xy_abs_parity_serp = +[](u16 x, u16 y, u16 width, u16 height) -> u16 { u16 yy = static_cast(y % height); u16 base = static_cast(yy * width); bool odd = (y & 1u) != 0u; // absolute row parity if (odd) { return static_cast(base + (width - 1 - x)); } return static_cast(base + x); }; XYMap segTopUF = XYMap::constructWithUserFunction(W, H_SEG, xy_abs_parity_serp, /*offset=*/0); XYMap segBottomUF = XYMap::constructWithUserFunction(W, H_SEG, xy_abs_parity_serp, /*offset=*/W * H_SEG); auto composedIndexUF = [&](u16 x, u16 y) -> u16 { if (y < H_SEG) { return segTopUF.mapToIndex(x, y); } return segBottomUF.mapToIndex(x, y); }; // With absolute parity honored, the composed mapping should match the 4x6 serpentine for (u16 y = 0; y < H_FULL; ++y) { for (u16 x = 0; x < W; ++x) { CHECK_EQ(composedIndexUF(x, y), fullSerp.mapToIndex(x, y)); } } } }