@@ -2622,6 +2622,104 @@ static void test_commitWithoutBegin_persistsButDoesNotReloadOrReboot()
26222622 TEST_ASSERT_EQUAL_UINT32 (0 , rebootAtMsec);
26232623}
26242624
2625+ // The abandonment path retires a transaction *outside* commit_edit_settings, so it decides both
2626+ // axes on its own. It must reach the same answers the commit would have, not fall back to the
2627+ // old implicit "reload the radio" - an abandoned display-only batch has no more business
2628+ // reprogramming the SX126x than a committed one does.
2629+ static void test_abandonedTransaction_liveOnlyBatch_expiresWithoutReloadOrReboot ()
2630+ {
2631+ rebootAtMsec = 0 ;
2632+ ConfigChangedCounter counter;
2633+ counter.observe (&service->configChanged );
2634+
2635+ sendBeginEdit ();
2636+ meshtastic_Config c = meshtastic_Config_init_zero;
2637+ c.which_payload_variant = meshtastic_Config_position_tag;
2638+ c.payload_variant .position = config.position ;
2639+ c.payload_variant .position .position_broadcast_secs = config.position .position_broadcast_secs + 60 ;
2640+ sendSetConfig (c);
2641+
2642+ const int before = mockMeshService->reloadCalls ;
2643+ testAdmin->ageEditTransaction ();
2644+ sendGetDeviceMetadata (); // any later admin message retires the stale transaction
2645+
2646+ TEST_ASSERT_FALSE (testAdmin->editTransactionOpen ());
2647+ TEST_ASSERT_EQUAL_INT (before + 1 , mockMeshService->reloadCalls ); // the deferred write still lands
2648+ TEST_ASSERT_EQUAL_INT (0 , counter.count );
2649+ TEST_ASSERT_EQUAL_UINT32 (0 , rebootAtMsec);
2650+ }
2651+
2652+ // The guard the other way: abandonment must not lose a real LoRa change's reconfigure either.
2653+ static void test_abandonedTransaction_loraSet_stillReloadsRadioOnExpiry ()
2654+ {
2655+ usePresetLongFast ();
2656+ ConfigChangedCounter counter;
2657+ counter.observe (&service->configChanged );
2658+
2659+ sendBeginEdit ();
2660+ meshtastic_Config c = meshtastic_Config_init_zero;
2661+ c.which_payload_variant = meshtastic_Config_lora_tag;
2662+ c.payload_variant .lora = config.lora ;
2663+ c.payload_variant .lora .modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
2664+ sendSetConfig (c);
2665+
2666+ TEST_ASSERT_EQUAL_INT (0 , counter.count ); // deferred, as for any open transaction
2667+
2668+ testAdmin->ageEditTransaction ();
2669+ sendGetDeviceMetadata ();
2670+
2671+ TEST_ASSERT_FALSE (testAdmin->editTransactionOpen ());
2672+ TEST_ASSERT_EQUAL_INT (1 , counter.count );
2673+ }
2674+
2675+ // Reboot is the one axis abandonment deliberately drops: the settings are already live in RAM and
2676+ // the client that asked for the restart is, by definition, gone.
2677+ static void test_abandonedTransaction_rebootingFieldDoesNotRebootOnExpiry ()
2678+ {
2679+ config.position .gps_mode = meshtastic_Config_PositionConfig_GpsMode_DISABLED; // known start state
2680+ rebootAtMsec = 0 ;
2681+
2682+ sendBeginEdit ();
2683+ meshtastic_Config c = meshtastic_Config_init_zero;
2684+ c.which_payload_variant = meshtastic_Config_position_tag;
2685+ c.payload_variant .position = config.position ;
2686+ c.payload_variant .position .gps_mode = meshtastic_Config_PositionConfig_GpsMode_ENABLED;
2687+ sendSetConfig (c);
2688+
2689+ testAdmin->ageEditTransaction ();
2690+ sendGetDeviceMetadata ();
2691+
2692+ TEST_ASSERT_FALSE (testAdmin->editTransactionOpen ());
2693+ TEST_ASSERT_EQUAL_UINT32 (0 , rebootAtMsec);
2694+ }
2695+
2696+ // Expiry must consume-and-clear the accumulated decisions, not just read them. A begin would reset
2697+ // them anyway, so the leak only shows through the one path that consumes them without a begin: a
2698+ // stray commit. Left uncleared, it inherits the abandoned LoRa transaction's answer and reloads.
2699+ static void test_abandonedTransaction_flagsDoNotLeakIntoAStrayCommit ()
2700+ {
2701+ usePresetLongFast ();
2702+
2703+ sendBeginEdit ();
2704+ meshtastic_Config c = meshtastic_Config_init_zero;
2705+ c.which_payload_variant = meshtastic_Config_lora_tag;
2706+ c.payload_variant .lora = config.lora ;
2707+ c.payload_variant .lora .modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST;
2708+ sendSetConfig (c);
2709+ testAdmin->ageEditTransaction ();
2710+ sendGetDeviceMetadata (); // abandoned, not committed - this is where the flags must be cleared
2711+
2712+ // Start counting after the expiry, so only the stray commit's reload would register.
2713+ rebootAtMsec = 0 ;
2714+ ConfigChangedCounter counter;
2715+ counter.observe (&service->configChanged );
2716+
2717+ sendCommitEdit (); // no matching begin
2718+
2719+ TEST_ASSERT_EQUAL_INT (0 , counter.count );
2720+ TEST_ASSERT_EQUAL_UINT32 (0 , rebootAtMsec);
2721+ }
2722+
26252723// -----------------------------------------------------------------------
26262724// Test runner
26272725// -----------------------------------------------------------------------
@@ -2818,6 +2916,10 @@ void setup()
28182916 RUN_TEST (test_transaction_liveOnlyBatch_neitherRebootsNorReloads);
28192917 RUN_TEST (test_transaction_flagsDoNotLeakIntoTheNextTransaction);
28202918 RUN_TEST (test_commitWithoutBegin_persistsButDoesNotReloadOrReboot);
2919+ RUN_TEST (test_abandonedTransaction_liveOnlyBatch_expiresWithoutReloadOrReboot);
2920+ RUN_TEST (test_abandonedTransaction_loraSet_stillReloadsRadioOnExpiry);
2921+ RUN_TEST (test_abandonedTransaction_rebootingFieldDoesNotRebootOnExpiry);
2922+ RUN_TEST (test_abandonedTransaction_flagsDoNotLeakIntoAStrayCommit);
28212923
28222924 exit (UNITY_END ());
28232925}
0 commit comments