Files
MeshCore/test/test_companion_prefs_tail.py
mikecarper 3e8c637866 Add Companion lost-status replies and repair room mailbox test adapters
Save an opt-in off/no/yes answer to private Am I lost questions from known
chat contacts. Keep reply traffic and ACK state bounded, preserve pending
user delivery, and expose the setting through local CLI and ESP32 WebConfig.

Retain compact STM32 application/filesystem boundaries, qualify the Tag BLE
queue allowance, and exercise saved-image compatibility and browser controls.
Fix stale room mailbox protocol dependencies in the ACL/route test fixtures.

Validation: ten Companion builds, sanitized production-path regressions,
preference fault/compatibility tests, browser tests, and 86 capacity/build
recipe checks passed. No live radio was flashed for this feature.
2026-10-10 19:24:42 -07:00

198 lines
9.8 KiB
Python

"""Verify the compact Companion tail preserves bytes and atomic failure handling."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from test_companion_preferences_transaction import HARNESS, esp_recovery_helpers
from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
LEGACY_TAIL = r'''
success = success && file.write((uint8_t *)&_prefs.flood_retry_attempts, 1) == 1;
success = success && file.write((uint8_t *)&_prefs.flood_retry_max_path, 1) == 1;
success = success && file.write((uint8_t *)&_prefs.flood_retry_group_max_path, 1) == 1;
success = success && file.write((uint8_t *)&_prefs.flood_retry_advert_enabled, 1) == 1;
success = success && file.write((uint8_t *)&_prefs.one_key_dm_enabled, 1) == 1;
success = success && file.write((uint8_t *)&_prefs.bluetooth_enabled, 1) == 1;
success = success && file.write((uint8_t *)&_prefs.gps_sync_interval_hours, 2) == 2;
success = success && file.write((uint8_t *)&_prefs.usb_debug_enabled, 1) == 1;
success = success && file.write((uint8_t *)&_prefs.lost_reply, 1) == 1;
'''
TESTS = r'''
int main() {
CompanionNodePrefs original;
strcpy(original.node_name, "old complete image");
original.freq=915.125f; original.ble_pin=456789;
original.flood_retry_attempts=4; original.flood_retry_max_path=3;
original.flood_retry_group_max_path=2; original.flood_retry_advert_enabled=1;
original.one_key_dm_enabled=1; original.bluetooth_enabled=0;
original.gps_sync_interval_hours=336; original.usb_debug_enabled=1;
original.lost_reply=2;
#ifdef TBEAM_1W
strcpy(original.fan_mode,"on"); original.fan_lo=32; original.fan_hi=45;
#endif
#if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
strcpy(original.wifi_ssid,"tail-test"); strcpy(original.wifi_pwd,"same bytes");
#endif
// Compare every byte against the previous scalar serializer, including
// arbitrary byte values that a raw caller might pass before CLI validation.
for (unsigned value=0; value<256; ++value) {
CompanionNodePrefs prefs=original;
prefs.flood_retry_attempts=uint8_t(value);
prefs.flood_retry_max_path=uint8_t(value+17);
prefs.flood_retry_group_max_path=uint8_t(value+37);
prefs.flood_retry_advert_enabled=uint8_t(value+53);
prefs.one_key_dm_enabled=uint8_t(value+71);
prefs.bluetooth_enabled=uint8_t(value+89);
prefs.gps_sync_interval_hours=uint16_t(value*257);
prefs.usb_debug_enabled=uint8_t(value+103);
prefs.lost_reply=uint8_t(value);
DataStore compact, legacy;
assert(compact.savePrefs(prefs,47.1,-122.2));
assert(legacy.savePrefsLegacy(prefs,47.1,-122.2));
const auto& image=compact.fs.files["/new_prefs"];
assert(image==legacy.fs.files["/new_prefs"]);
#if defined(TBEAM_1W)
assert(image.size()==243);
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
assert(image.size()==333);
#else
assert(image.size()==236);
#endif
const uint8_t expected[]={prefs.flood_retry_attempts,prefs.flood_retry_max_path,
prefs.flood_retry_group_max_path,prefs.flood_retry_advert_enabled,
prefs.one_key_dm_enabled,prefs.bluetooth_enabled,0,0,prefs.usb_debug_enabled,
prefs.lost_reply};
assert(!memcmp(image.data()+image.size()-10,expected,6));
assert(!memcmp(image.data()+image.size()-4,&prefs.gps_sync_interval_hours,2));
assert(image[image.size()-2]==prefs.usb_debug_enabled);
assert(image.back()==prefs.lost_reply);
}
DataStore initial; assert(initial.savePrefs(original,47.1,-122.2));
const auto disk=initial.fs.files["/new_prefs"];
CompanionNodePrefs changed=original;
strcpy(changed.node_name,"new complete image"); changed.freq=910.5f;
changed.flood_retry_attempts=7; changed.bluetooth_enabled=1;
changed.gps_sync_interval_hours=24; changed.usb_debug_enabled=0;
changed.lost_reply=1;
auto expectOld=[&](DataStore& store) {
assert(store.fs.files["/new_prefs"]==disk);
assert(!store.fs.exists("/new_prefs.tmp"));
DataStore reboot; reboot.fs.files["/new_prefs"]=disk;
CompanionNodePrefs loaded; double lat=0,lon=0;
assert(reboot.loadPrefs(loaded,lat,lon));
assert(!strcmp(loaded.node_name,original.node_name));
assert(loaded.freq==original.freq && loaded.ble_pin==original.ble_pin);
assert(loaded.flood_retry_attempts==4 && loaded.bluetooth_enabled==0);
assert(loaded.gps_sync_interval_hours==336 && loaded.usb_debug_enabled==1);
assert(loaded.lost_reply==2);
assert(lat==47.1 && lon==-122.2);
};
// A short return at each boundary of the single final write must abort the
// transaction, including either byte of the two-byte GPS field.
for (unsigned boundary=0; boundary<10; ++boundary) {
DataStore store; store.fs.files["/new_prefs"]=disk;
store.fs.fail_write_after=int(disk.size()-10+boundary);
assert(!store.savePrefs(changed,42.3,-121.2)); expectOld(store);
store.fs.fail_write_after=-1;
assert(store.savePrefs(changed,42.3,-121.2));
assert(store.fs.files["/new_prefs"]!=disk);
}
// Fully written bytes still cannot replace the live image until readback
// verifies every final-tail boundary. LittleFS's retries also must fail.
for (unsigned boundary=0; boundary<10; ++boundary) {
DataStore store; store.fs.files["/new_prefs"]=disk;
store.fs.fail_read_after=int(disk.size()-10+boundary);
assert(!store.savePrefs(changed,42.3,-121.2)); expectOld(store);
}
{
DataStore store; store.fs.files["/new_prefs"]=disk;
store.fs.fail_read_open=true;
assert(!store.savePrefs(changed,42.3,-121.2)); expectOld(store);
}
#if defined(STM32_PLATFORM) || defined(NRF52_PLATFORM)
const unsigned rename_steps=1;
#else
const unsigned rename_steps=2;
#endif
for (unsigned step=1; step<=rename_steps; ++step) {
DataStore store; store.fs.files["/new_prefs"]=disk;
store.fs.fail_rename=int(step);
assert(!store.savePrefs(changed,42.3,-121.2)); expectOld(store);
}
assert(initial.savePrefs(changed,42.3,-121.2));
CompanionNodePrefs loaded; double lat=0,lon=0;
assert(initial.loadPrefs(loaded,lat,lon));
assert(!strcmp(loaded.node_name,changed.node_name));
assert(loaded.flood_retry_attempts==7 && loaded.bluetooth_enabled==1);
assert(loaded.gps_sync_interval_hours==24 && loaded.usb_debug_enabled==0);
assert(loaded.lost_reply==1);
assert(lat==42.3 && lon==-121.2);
}
'''
class CompanionPrefsTailTests(unittest.TestCase):
def test_full_image_parity_and_atomic_tail_faults(self):
source = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text()
save = method(source, 'bool DataStore::savePrefs(')
# Keep the complete original scalar writer independent of any future
# production field-table changes, including every native tail byte.
legacy_save = (ROOT / 'test/fixtures/companion_prefs_storage/legacy_writer.inc').read_text()
methods = esp_recovery_helpers(source) + '\n'.join(method(source, signature) for signature in (
'bool DataStore::loadPrefs(', 'bool DataStore::loadPrefsInt('))
methods += '\n' + save + '\n' + legacy_save
code = HARNESS.split('int main(')[0].replace('@METHODS@', methods)
code = code.replace(' bool savePrefs(const CompanionNodePrefs&, double, double);',
' bool savePrefs(const CompanionNodePrefs&, double, double);\n'
' bool savePrefsLegacy(const CompanionNodePrefs&, double, double);')
with tempfile.TemporaryDirectory(prefix='meshcore-prefs-tail-') as directory:
work = Path(directory)
(work / 'Utils.h').write_text('''#pragma once
#include <Arduino.h>
#include <cassert>
namespace mesh { struct Utils {
static void printHex(Stream&,const uint8_t*,size_t){assert(false);}
static void fromHex(uint8_t*,size_t,const char*){assert(false);}
}; }
''')
(work / 'platform_shim.h').write_text('''#include <cstdlib>
#include <cstdio>
inline char* utoa(unsigned int value,char* output,int base){
if(base!=10)abort();sprintf(output,"%u",value);return output;
}
''')
transaction = (ROOT / 'src/helpers/ContactFileTransaction.h').read_text()
(work / 'ContactFileTransaction.h').write_text(transaction.replace(
'#include "IdentityStore.h"', '#include <helpers/IdentityStore.h>'))
cpp = work / 'test.cpp'
cpp.write_text(code + TESTS)
for platform in ('STM32_PLATFORM', 'NRF52_PLATFORM', 'ESP32_PLATFORM', 'RP2040_PLATFORM',
'ESP32_PLATFORM,TBEAM_1W', 'RP2040_PLATFORM,ENABLE_WIFI_INTERFACE'):
with self.subTest(platform=platform):
binary = work / 'test'
sanitizers = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie']
built = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17',
*['-D' + flag + '=1' for flag in platform.split(',')],
*sanitizers, '-include', str(work / 'platform_shim.h'),
'-I', str(work), '-I', str(ROOT / 'test/fixtures/radio_profiles/mocks'),
'-I', str(ROOT / 'test/mocks'), '-I', str(ROOT / 'src'),
'-I', str(ROOT / 'src/helpers'), '-I', str(ROOT), str(cpp),
*[str(ROOT / 'src/helpers' / name) for name in (
'ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp',
'CommonRadioPrefs.cpp', 'TxtDataHelpers.cpp')],
'-o', str(binary)], capture_output=True, text=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
if __name__ == '__main__':
unittest.main()