From 676721fe8f0213bb3ce33202f6acc3cb935ab473 Mon Sep 17 00:00:00 2001 From: Peter Feerick Date: Thu, 3 Sep 2026 14:02:33 +1000 Subject: [PATCH] test(radio): enshrine rc-soar.com documented mixer/LS behavior (#7751) Co-authored-by: Claude Sonnet 5 --- radio/src/tests/functions.cpp | 6 +- radio/src/tests/gtests.h | 17 ++ radio/src/tests/mixer.cpp | 379 +++++++++++++++++++++++++++++++--- radio/src/tests/switches.cpp | 100 +++++++-- 4 files changed, 451 insertions(+), 51 deletions(-) diff --git a/radio/src/tests/functions.cpp b/radio/src/tests/functions.cpp index 570ac70be..cfa527e4b 100644 --- a/radio/src/tests/functions.cpp +++ b/radio/src/tests/functions.cpp @@ -75,10 +75,8 @@ TEST_F(SpecialFunctionsTest, FlightReset) #if defined(GVARS) TEST_F(SpecialFunctionsTest, GvarsInc) { - int sw; - for (sw = 0; sw < switchGetMaxAllSwitches(); sw += 1) - if (g_model.getSwitchType(sw) == SWITCH_3POS) - break; + int sw = findHwSwitch(SWITCH_3POS); + if (sw < 0) return; int swPos = (sw * 3) + SWSRC_FIRST_SWITCH; simuSetSwitch(sw, 0); // SA- diff --git a/radio/src/tests/gtests.h b/radio/src/tests/gtests.h index 74f3bb74d..dd116c223 100644 --- a/radio/src/tests/gtests.h +++ b/radio/src/tests/gtests.h @@ -101,6 +101,23 @@ inline void MIXER_RESET() logicalSwitchesReset(); } +// Find a hardware switch matching the given type (SWITCH_3POS, SWITCH_2POS, +// etc.), skipping function switches. Pass SWITCH_NONE to match any type. +// Pass startAfter to find a second (or further) distinct switch, continuing +// the scan after that index. Returns the switch index, or -1 if none found. +inline int findHwSwitch(int type = SWITCH_NONE, int startAfter = -1) +{ + for (int sw = startAfter + 1; sw < switchGetMaxAllSwitches(); sw++) { + auto swType = g_model.getSwitchType(sw); + if (swType == SWITCH_NONE) continue; +#if defined(FUNCTION_SWITCHES) + if (switchIsCustomSwitch(sw)) continue; +#endif + if (type == SWITCH_NONE || swType == type) return sw; + } + return -1; +} + inline void TELEMETRY_RESET() { telemetryData.clear(); diff --git a/radio/src/tests/mixer.cpp b/radio/src/tests/mixer.cpp index 0957d0f77..a926dfc3d 100644 --- a/radio/src/tests/mixer.cpp +++ b/radio/src/tests/mixer.cpp @@ -711,11 +711,8 @@ TEST_F(MixerTest, SlowOnPhase) TEST_F(MixerTest, SlowOnSwitchSource) { - int sw; - for (sw = 0; sw < switchGetMaxAllSwitches(); sw += 1) - if (g_model.getSwitchType(sw) == SWITCH_3POS) - break; - if (sw >= switchGetMaxAllSwitches()) return; + int sw = findHwSwitch(SWITCH_3POS); + if (sw < 0) return; g_model.mixData[0].destCh = 0; g_model.mixData[0].mltpx = MLTPX_ADD; @@ -759,11 +756,8 @@ TEST_F(MixerTest, SlowOnPhasePrec10ms) TEST_F(MixerTest, SlowOnSwitchSourcePrec10ms) { - int sw; - for (sw = 0; sw < switchGetMaxAllSwitches(); sw += 1) - if (g_model.getSwitchType(sw) == SWITCH_3POS) - break; - if (sw >= switchGetMaxAllSwitches()) return; + int sw = findHwSwitch(SWITCH_3POS); + if (sw < 0) return; g_model.mixData[0].destCh = 0; g_model.mixData[0].mltpx = MLTPX_ADD; @@ -798,11 +792,8 @@ TEST_F(MixerTest, SlowDisabledOnStartup) TEST_F(MixerTest, DelayOnSwitch) { - int sw; - for (sw = 0; sw < switchGetMaxAllSwitches(); sw += 1) - if (g_model.getSwitchType(sw) == SWITCH_3POS) - break; - if (sw >= switchGetMaxAllSwitches()) return; + int sw = findHwSwitch(SWITCH_3POS); + if (sw < 0) return; int swPos = (sw * 3) + SWSRC_FIRST_SWITCH + 2; g_model.mixData[0].destCh = 0; @@ -833,11 +824,8 @@ TEST_F(MixerTest, DelayOnSwitch) TEST_F(MixerTest, DelayOnSwitch2) { - int sw; - for (sw = 0; sw < switchGetMaxAllSwitches(); sw += 1) - if (g_model.getSwitchType(sw) == SWITCH_3POS) - break; - if (sw >= switchGetMaxAllSwitches()) return; + int sw = findHwSwitch(SWITCH_3POS); + if (sw < 0) return; g_model.mixData[0].destCh = 0; g_model.mixData[0].mltpx = MLTPX_ADD; @@ -1031,11 +1019,8 @@ TEST(Trainer, UnpluggedTest) TEST_F(MixerTest, flightModeTransition) { - int sw; - for (sw = 0; sw < switchGetMaxAllSwitches(); sw += 1) - if (g_model.getSwitchType(sw) == SWITCH_3POS) - break; - if (sw >= switchGetMaxAllSwitches()) return; + int sw = findHwSwitch(SWITCH_3POS); + if (sw < 0) return; int swPos = (sw * 3) + SWSRC_FIRST_SWITCH + 2; SYSTEM_RESET(); @@ -1249,3 +1234,347 @@ TEST_F(TrimsTest, invertedThrottlePlusThrottleTrimWithCrossTrims) EXPECT_EQ(channelOutputs[THR_CHAN], +1024); EXPECT_EQ(channelOutputs[ELE_CHAN], 0); } + +// ========================================================================== +// rc-soar.com documented behavior tests +// https://rc-soar.com/edgetx/index.php +// Enshrine mixer, flight-mode, and channel-cascade semantics so that +// refactoring does not silently break real-world user setups. +// ========================================================================== + +// Multiplex ADD: lines accumulate additively on the same channel. +TEST_F(MixerTest, MultiplexAdd) +{ + g_model.mixData[0].destCh = 0; + g_model.mixData[0].mltpx = MLTPX_ADD; + g_model.mixData[0].srcRaw = MIXSRC_MAX; + g_model.mixData[0].weight = makeSourceNumVal(60); + g_model.mixData[1].destCh = 0; + g_model.mixData[1].mltpx = MLTPX_ADD; + g_model.mixData[1].srcRaw = MIXSRC_MAX; + g_model.mixData[1].weight = makeSourceNumVal(40); + + evalFlightModeMixes(e_perout_mode_normal, 0); + EXPECT_EQ(chans[0], CHANNEL_MAX); // 60% + 40% = 100% +} + +// Multiplex REPL: second line replaces whatever the first produced. +TEST_F(MixerTest, MultiplexReplace) +{ + g_model.mixData[0].destCh = 0; + g_model.mixData[0].mltpx = MLTPX_ADD; + g_model.mixData[0].srcRaw = MIXSRC_MAX; + g_model.mixData[0].weight = makeSourceNumVal(100); + g_model.mixData[1].destCh = 0; + g_model.mixData[1].mltpx = MLTPX_REPL; + g_model.mixData[1].srcRaw = MIXSRC_MAX; + g_model.mixData[1].weight = makeSourceNumVal(-50); + + evalFlightModeMixes(e_perout_mode_normal, 0); + EXPECT_EQ(chans[0], -CHANNEL_MAX / 2); // REPL overwrites to -50% +} + +// Multiplex MUL: multiplies with the result of all lines above. +TEST_F(MixerTest, MultiplexMultiplyBasic) +{ + g_model.mixData[0].destCh = 0; + g_model.mixData[0].mltpx = MLTPX_ADD; + g_model.mixData[0].srcRaw = MIXSRC_MAX; + g_model.mixData[0].weight = makeSourceNumVal(100); + g_model.mixData[1].destCh = 0; + g_model.mixData[1].mltpx = MLTPX_MUL; + g_model.mixData[1].srcRaw = MIXSRC_MAX; + g_model.mixData[1].weight = makeSourceNumVal(50); + + evalFlightModeMixes(e_perout_mode_normal, 0); + EXPECT_EQ(chans[0], CHANNEL_MAX / 2); // 100% * 50% = 50% +} + +// Multiplex MUL is order-sensitive: ADD+ADD+MUL differs from ADD+MUL+ADD. +TEST_F(MixerTest, MultiplexMultiplyOrderSensitive) +{ + // Case A: ADD(60) + ADD(40) + MUL(50) = (60+40)*50% = 50% + g_model.mixData[0].destCh = 0; + g_model.mixData[0].mltpx = MLTPX_ADD; + g_model.mixData[0].srcRaw = MIXSRC_MAX; + g_model.mixData[0].weight = makeSourceNumVal(60); + g_model.mixData[1].destCh = 0; + g_model.mixData[1].mltpx = MLTPX_ADD; + g_model.mixData[1].srcRaw = MIXSRC_MAX; + g_model.mixData[1].weight = makeSourceNumVal(40); + g_model.mixData[2].destCh = 0; + g_model.mixData[2].mltpx = MLTPX_MUL; + g_model.mixData[2].srcRaw = MIXSRC_MAX; + g_model.mixData[2].weight = makeSourceNumVal(50); + + evalFlightModeMixes(e_perout_mode_normal, 0); + int32_t caseA = chans[0]; + EXPECT_EQ(caseA, CHANNEL_MAX / 2); // 100% * 50% = 50% + + // Case B: ADD(60) + MUL(50) + ADD(40) = 60*50% + 40 = 30+40 = 70% + MIXER_RESET(); + g_model.mixData[0].destCh = 0; + g_model.mixData[0].mltpx = MLTPX_ADD; + g_model.mixData[0].srcRaw = MIXSRC_MAX; + g_model.mixData[0].weight = makeSourceNumVal(60); + g_model.mixData[1].destCh = 0; + g_model.mixData[1].mltpx = MLTPX_MUL; + g_model.mixData[1].srcRaw = MIXSRC_MAX; + g_model.mixData[1].weight = makeSourceNumVal(50); + g_model.mixData[2].destCh = 0; + g_model.mixData[2].mltpx = MLTPX_ADD; + g_model.mixData[2].srcRaw = MIXSRC_MAX; + g_model.mixData[2].weight = makeSourceNumVal(40); + + evalFlightModeMixes(e_perout_mode_normal, 0); + int32_t caseB = chans[0]; + + EXPECT_NE(caseA, caseB); // order matters +} + +// Weight-then-offset: output = (source * weight) + offset. +TEST_F(MixerTest, WeightThenOffset) +{ + g_model.mixData[0].destCh = 0; + g_model.mixData[0].srcRaw = MIXSRC_FIRST_STICK; + g_model.mixData[0].weight = makeSourceNumVal(50); + g_model.mixData[0].offset = makeSourceNumVal(50); + + // Stick at +100%: 50% of 1024 + 50% offset + anaSetFiltered(inputMappingConvertMode(0), +1024); + evalFlightModeMixes(e_perout_mode_normal, 0); + int32_t full = chans[0]; + + // Stick at 0: 0 + 50% offset + anaSetFiltered(inputMappingConvertMode(0), 0); + evalFlightModeMixes(e_perout_mode_normal, 0); + int32_t mid = chans[0]; + + // Stick at -100%: -50% + 50% offset = 0 + anaSetFiltered(inputMappingConvertMode(0), -1024); + evalFlightModeMixes(e_perout_mode_normal, 0); + int32_t low = chans[0]; + + // full should be ~100%, mid ~50%, low ~0% + EXPECT_NEAR(full, CHANNEL_MAX, CHANNEL_MAX / 100); + EXPECT_NEAR(mid, CHANNEL_MAX / 2, CHANNEL_MAX / 100); + EXPECT_NEAR(low, 0, CHANNEL_MAX / 100); +} + +// Cascaded channels bypass output clipping: a channel used as a mix source +// carries its internal (>100%) value, not the clipped output. +TEST_F(MixerTest, CascadedChannelBypassesOutputClipping) +{ + // CH0: stick at 200% weight (overdrives to 200% internally) + g_model.mixData[0].destCh = 0; + g_model.mixData[0].srcRaw = MIXSRC_FIRST_STICK; + g_model.mixData[0].weight = makeSourceNumVal(200); + // CH1: reads CH0 at 50% weight — if unclipped, 200%*50% = 100% + g_model.mixData[1].destCh = 1; + g_model.mixData[1].srcRaw = MIXSRC_FIRST_CH; + g_model.mixData[1].weight = makeSourceNumVal(50); + + anaSetFiltered(inputMappingConvertMode(0), +1024); + evalMixes(1); + + // CH0 output is clipped to 100% + EXPECT_EQ(channelOutputs[0], 1024); + // CH1 sees the unclipped CH0 (200%), applies 50% → 100% + // If CH0 were clipped before cascading, CH1 would be only 50%. + EXPECT_GT(channelOutputs[1], 512); // must be more than 50% + EXPECT_NEAR(channelOutputs[1], 1024, 2); // should be ~100% +} + +// Flight mode priority: FM1 has highest priority, FM0 is fallback. +// getFlightMode() iterates FM1..FM8, first match wins; FM0 returned if none. +TEST_F(MixerTest, FlightModePriority) +{ + // Find two distinct 3-pos switches + int sw1 = findHwSwitch(SWITCH_3POS); + int sw2 = findHwSwitch(SWITCH_3POS, sw1); + if (sw2 < 0) return; // not enough switches on this target + + int sw1Up = (sw1 * 3) + SWSRC_FIRST_SWITCH; + int sw2Up = (sw2 * 3) + SWSRC_FIRST_SWITCH; + + // FM1 activated by sw1↑, FM2 by sw2↑ + g_model.flightModeData[1].swtch = sw1Up; + g_model.flightModeData[2].swtch = sw2Up; + + // Three REPL mix lines, one per FM, using distinguishable weights: + // FM0-only: weight=10 FM1-only: weight=20 FM2-only: weight=30 + // flightModes bitmask: bit set = DISABLED in that FM. + g_model.mixData[0].destCh = 0; + g_model.mixData[0].mltpx = MLTPX_REPL; + g_model.mixData[0].srcRaw = MIXSRC_MAX; + g_model.mixData[0].weight = makeSourceNumVal(10); + g_model.mixData[0].flightModes = ~1u & 0x1FF; // enabled only in FM0 + + g_model.mixData[1].destCh = 0; + g_model.mixData[1].mltpx = MLTPX_REPL; + g_model.mixData[1].srcRaw = MIXSRC_MAX; + g_model.mixData[1].weight = makeSourceNumVal(20); + g_model.mixData[1].flightModes = ~2u & 0x1FF; // enabled only in FM1 + + g_model.mixData[2].destCh = 0; + g_model.mixData[2].mltpx = MLTPX_REPL; + g_model.mixData[2].srcRaw = MIXSRC_MAX; + g_model.mixData[2].weight = makeSourceNumVal(30); + g_model.mixData[2].flightModes = ~4u & 0x1FF; // enabled only in FM2 + + // Neither switch active → FM0 (fallback) + simuSetSwitch(sw1, 0); + simuSetSwitch(sw2, 0); + evalMixes(1); + EXPECT_NEAR(channelOutputs[0], 1024 * 10 / 100, 1); + + // Both switches active → FM1 wins (higher priority than FM2) + simuSetSwitch(sw1, -1); + simuSetSwitch(sw2, -1); + evalMixes(1); + EXPECT_NEAR(channelOutputs[0], 1024 * 20 / 100, 1); + + // Only sw2 active → FM2 + simuSetSwitch(sw1, 0); + simuSetSwitch(sw2, -1); + evalMixes(1); + EXPECT_NEAR(channelOutputs[0], 1024 * 30 / 100, 1); +} + +// Cumulative weight through cascaded channels: +// CH0 = stick * 80%, CH1 = CH0 * 25% → CH1 should be stick * 20%. +TEST_F(MixerTest, CascadedWeightMultiplication) +{ + g_model.mixData[0].destCh = 0; + g_model.mixData[0].srcRaw = MIXSRC_FIRST_STICK; + g_model.mixData[0].weight = makeSourceNumVal(80); + g_model.mixData[1].destCh = 1; + g_model.mixData[1].srcRaw = MIXSRC_FIRST_CH; + g_model.mixData[1].weight = makeSourceNumVal(25); + + anaSetFiltered(inputMappingConvertMode(0), +1024); + evalMixes(1); + + // CH0 = 80% of 1024 ≈ 819 + EXPECT_NEAR(channelOutputs[0], 1024 * 80 / 100, 2); + // CH1 = 25% of CH0 = 20% of 1024 ≈ 205 + EXPECT_NEAR(channelOutputs[1], 1024 * 20 / 100, 2); +} + +// Sequencer pattern (rc-soar.com/edgetx/setups/sequencer): +// CH0 = timebase driven by a switch with slow up/down (linear ramp). +// CH1 = servo channel reading CH0 through a custom curve. +// A flat curve segment produces a "pause" where the servo holds position +// while the timebase continues to ramp. +TEST_F(MixerTest, SequencerSlowRampThroughCurve) +{ + int sw = findHwSwitch(SWITCH_3POS); + if (sw < 0) return; + + // --- Curve setup: 5-point standard curve on slot 0 --- + // Points at x = -100, -50, 0, +50, +100 (standard = evenly spaced) + // y-values: -100, -100, 0, 0, +100 + // Segment 1 (-100..-50): flat at -100 (pause) + // Segment 2 (-50..0): ramp from -100 to 0 + // Segment 3 (0..+50): flat at 0 (pause) + // Segment 4 (+50..+100): ramp from 0 to +100 + g_model.curves[0].type = CURVE_TYPE_STANDARD; + g_model.curves[0].smooth = 0; // linear interpolation, no smoothing + g_model.curves[0].points = 0; // 0 means 5 points (default) + int8_t *pts = curveAddress(0); + pts[0] = -100; // x=-100% + pts[1] = -100; // x=-50% (flat: pause) + pts[2] = 0; // x=0% + pts[3] = 0; // x=+50% (flat: pause) + pts[4] = 100; // x=+100% + + // --- CH0: timebase — switch source with slow --- + // speedUp=10 → 1.0s full traversal (-100% to +100%), 100 ticks + g_model.mixData[0].destCh = 0; + g_model.mixData[0].mltpx = MLTPX_ADD; + g_model.mixData[0].srcRaw = sw + MIXSRC_FIRST_SWITCH; + g_model.mixData[0].weight = makeSourceNumVal(100); + g_model.mixData[0].speedUp = 10; // 1.0s + g_model.mixData[0].speedDown = 10; // 1.0s + + // --- CH1: servo — reads CH0 through curve --- + g_model.mixData[1].destCh = 1; + g_model.mixData[1].mltpx = MLTPX_ADD; + g_model.mixData[1].srcRaw = MIXSRC_FIRST_CH; + g_model.mixData[1].weight = makeSourceNumVal(100); + g_model.mixData[1].curve.type = CURVE_REF_CUSTOM; + g_model.mixData[1].curve.value = makeSourceNumVal(1); // curve index 0 (1-based) + + s_mixer_first_run_done = true; + + // Start: switch up → CH0 targets -100% + simuSetSwitch(sw, -1); + for (int i = 0; i < 110; i++) + evalFlightModeMixes(e_perout_mode_normal, 1); + evalMixes(1); // run limits to populate channelOutputs + + int16_t timebaseStart = channelOutputs[0]; + int16_t servoStart = channelOutputs[1]; + // Timebase at -100%, curve maps -100→-100 + EXPECT_NEAR(timebaseStart, -1024, 20); + EXPECT_NEAR(servoStart, -1024, 20); + + // Flip switch down → ramp begins from -100% toward +100% + simuSetSwitch(sw, 1); + + // Advance 25 ticks (25% of 1.0s = timebase at ~-50%) + // Curve: flat from -100% to -50%, so servo should still be near -100% + for (int i = 0; i < 25; i++) + evalFlightModeMixes(e_perout_mode_normal, 1); + evalMixes(1); + + int16_t timebaseQ1 = channelOutputs[0]; + int16_t servoQ1 = channelOutputs[1]; + EXPECT_GT(timebaseQ1, timebaseStart + 100) << "Timebase should have moved"; + EXPECT_NEAR(servoQ1, -1024, 60) << "Servo should hold during flat segment (pause)"; + + // Advance to 50 ticks total (50% = timebase at ~0%) + // Curve: ramp from -100 to 0, so servo should be near 0% + for (int i = 0; i < 25; i++) + evalFlightModeMixes(e_perout_mode_normal, 1); + evalMixes(1); + + int16_t timebaseMid = channelOutputs[0]; + int16_t servoMid = channelOutputs[1]; + EXPECT_NEAR(timebaseMid, 0, 60); + EXPECT_NEAR(servoMid, 0, 60); + + // Advance to 75 ticks (75% = timebase at ~+50%) + // Curve: flat from 0 to +50%, so servo should hold near 0% + for (int i = 0; i < 25; i++) + evalFlightModeMixes(e_perout_mode_normal, 1); + evalMixes(1); + + int16_t timebaseQ3 = channelOutputs[0]; + int16_t servoQ3 = channelOutputs[1]; + EXPECT_GT(timebaseQ3, timebaseMid + 100) << "Timebase should have advanced"; + // Servo should be near the flat segment value (0), but may slightly + // overshoot into the final ramp segment due to discrete stepping. + EXPECT_NEAR(servoQ3, 0, 150) << "Servo should be near flat segment"; + EXPECT_LT(servoQ3, 512) << "Servo must not yet reach the final ramp"; + + // Advance to ~100 ticks (100% = timebase at +100%) + // Curve: ramp from 0 to +100, so servo should be near +100% + for (int i = 0; i < 30; i++) + evalFlightModeMixes(e_perout_mode_normal, 1); + evalMixes(1); + + int16_t timebaseEnd = channelOutputs[0]; + int16_t servoEnd = channelOutputs[1]; + EXPECT_NEAR(timebaseEnd, 1024, 20); + EXPECT_NEAR(servoEnd, 1024, 40); + + // Reverse: flip switch up — sequence should reverse automatically + simuSetSwitch(sw, -1); + for (int i = 0; i < 110; i++) + evalFlightModeMixes(e_perout_mode_normal, 1); + evalMixes(1); + + EXPECT_NEAR(channelOutputs[0], -1024, 20) << "Timebase should return to -100%"; + EXPECT_NEAR(channelOutputs[1], -1024, 40) << "Servo should return to start via reversed curve"; +} diff --git a/radio/src/tests/switches.cpp b/radio/src/tests/switches.cpp index 1758c9a09..7f1d7e013 100644 --- a/radio/src/tests/switches.cpp +++ b/radio/src/tests/switches.cpp @@ -40,17 +40,14 @@ void setLogicalSwitch(int index, uint16_t _func, int16_t _v1, int16_t _v2, int16 #define SWSRC_SW1 (SWSRC_FIRST_LOGICAL_SWITCH) #define SWSRC_SW2 (SWSRC_FIRST_LOGICAL_SWITCH + 1) -#if defined(PCBTARANIS) TEST(getSwitch, OldTypeStickyCSW) { RADIO_RESET(); MODEL_RESET(); MIXER_RESET(); - int sw; - for (sw = 0; sw < switchGetMaxAllSwitches(); sw += 1) - if (g_model.getSwitchType(sw) == SWITCH_3POS) - break; + int sw = findHwSwitch(SWITCH_3POS); + if (sw < 0) return; // no 3-pos switch on this target int swPos = (sw * 3) + SWSRC_FIRST_SWITCH; setLogicalSwitch(0, LS_FUNC_AND, swPos, SWSRC_NONE); @@ -79,7 +76,76 @@ TEST(getSwitch, OldTypeStickyCSW) EXPECT_FALSE(getSwitch(SWSRC_SW1)); EXPECT_FALSE(getSwitch(SWSRC_SW2)); } -#endif + +// Tick logical switches for N cycles. +static void tickLogicalSwitches(int n) +{ + for (int i = 0; i < n; i++) { + logicalSwitchesTimerTick(); + evalLogicalSwitches(); + } +} + +// Sticky LS latches at startup when the set-condition is already satisfied. +// After logicalSwitchesReset(), lastValue starts at its CS_LAST_VALUE_INIT +// sentinel, so the first logicalSwitchesTimerTick() sees a 0->1 edge on the +// set-condition and latches immediately. +TEST(getSwitch, StickyLatchesAtStartupWhenSetConditionTrue) +{ + RADIO_RESET(); + MODEL_RESET(); + MIXER_RESET(); + + int sw = findHwSwitch(SWITCH_3POS); + if (sw < 0) return; // no 3-pos switch on this target + int swUp = (sw * 3) + SWSRC_FIRST_SWITCH; + int swDn = swUp + 2; + + // Pre-set the switch to the "set" position BEFORE creating the LS + simuSetSwitch(sw, -1); + + setLogicalSwitch(0, LS_FUNC_STICKY, swUp, swDn); + + // Simulate power-on + logicalSwitchesReset(); + + // After reset + tick, the set-condition is already true so sticky latches + tickLogicalSwitches(10); + EXPECT_TRUE(getSwitch(SWSRC_SW1)) + << "Sticky latches at startup when set-condition is already true"; + + // Reset via the reset-condition (swDn) + simuSetSwitch(sw, 1); + tickLogicalSwitches(5); + EXPECT_FALSE(getSwitch(SWSRC_SW1)) << "Reset condition should unlatch sticky"; +} + +// Sticky LS stays false at startup when the set-condition is NOT satisfied. +TEST(getSwitch, StickyFalseAtStartupWhenSetConditionFalse) +{ + RADIO_RESET(); + MODEL_RESET(); + MIXER_RESET(); + + int sw = findHwSwitch(SWITCH_3POS); + if (sw < 0) return; // no 3-pos switch on this target + int swUp = (sw * 3) + SWSRC_FIRST_SWITCH; + int swDn = swUp + 2; + + // Switch in mid position - set-condition is false + simuSetSwitch(sw, 0); + + setLogicalSwitch(0, LS_FUNC_STICKY, swUp, swDn); + + logicalSwitchesReset(); + tickLogicalSwitches(10); + EXPECT_FALSE(getSwitch(SWSRC_SW1)) << "Sticky should not latch when condition is false"; + + // Now trigger a rising edge -> should latch + simuSetSwitch(sw, -1); + tickLogicalSwitches(5); + EXPECT_TRUE(getSwitch(SWSRC_SW1)) << "Sticky should latch on rising edge"; +} TEST(getSwitch, nullSW) { @@ -88,7 +154,6 @@ TEST(getSwitch, nullSW) } -#if defined(PCBTARANIS) TEST(getSwitch, inputWithTrim) { MODEL_RESET(); @@ -107,7 +172,6 @@ TEST(getSwitch, inputWithTrim) evalLogicalSwitches(); EXPECT_TRUE(getSwitch(SWSRC_SW1)); } -#endif TEST(evalLogicalSwitches, playFile) { @@ -143,13 +207,9 @@ TEST(evalLogicalSwitches, playFile) TEST(getSwitch, edgeInstant) { - int sw1, sw2; - for (sw1 = 0; sw1 < switchGetMaxAllSwitches(); sw1 += 1) - if (g_model.getSwitchType(sw1) == SWITCH_3POS) - break; - for (sw2 = sw1 + 1; sw2 < switchGetMaxAllSwitches(); sw2 += 1) - if (g_model.getSwitchType(sw2) == SWITCH_3POS) - break; + int sw1 = findHwSwitch(SWITCH_3POS); + int sw2 = findHwSwitch(SWITCH_3POS, sw1); + if (sw1 < 0 || sw2 < 0) return; // needs two distinct 3-pos switches int sw1Pos = (sw1 * 3) + SWSRC_FIRST_SWITCH; int sw2Pos = (sw2 * 3) + SWSRC_FIRST_SWITCH; @@ -265,13 +325,9 @@ TEST(getSwitch, edgeInstant) TEST(getSwitch, edgeRelease) { - int sw1, sw2; - for (sw1 = 0; sw1 < switchGetMaxAllSwitches(); sw1 += 1) - if (g_model.getSwitchType(sw1) == SWITCH_3POS) - break; - for (sw2 = sw1 + 1; sw2 < switchGetMaxAllSwitches(); sw2 += 1) - if (g_model.getSwitchType(sw2) == SWITCH_3POS) - break; + int sw1 = findHwSwitch(SWITCH_3POS); + int sw2 = findHwSwitch(SWITCH_3POS, sw1); + if (sw1 < 0 || sw2 < 0) return; // needs two distinct 3-pos switches int sw1Pos = (sw1 * 3) + SWSRC_FIRST_SWITCH; int sw2Pos = (sw2 * 3) + SWSRC_FIRST_SWITCH;