Files
MeshCore/test/test_companion_prefs_tail.py
T

199 lines
10 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"];
const size_t historical_size=image.size()-mesh::companion::traffic::FooterSize;
assert(std::vector<uint8_t>(image.begin(),image.begin()+historical_size)==legacy.fs.files["/new_prefs"]);
#if defined(TBEAM_1W)
assert(historical_size==243);
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
assert(historical_size==333);
#else
assert(historical_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()+historical_size-10,expected,6));
assert(!memcmp(image.data()+historical_size-4,&prefs.gps_sync_interval_hours,2));
assert(image[historical_size-2]==prefs.usb_debug_enabled);
assert(image[historical_size-1]==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+mesh::companion::traffic::FooterSize; ++boundary) {
DataStore store; store.fs.files["/new_prefs"]=disk;
store.fs.fail_write_after=int(disk.size()-mesh::companion::traffic::FooterSize-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+mesh::companion::traffic::FooterSize; ++boundary) {
DataStore store; store.fs.files["/new_prefs"]=disk;
store.fs.fail_read_after=int(disk.size()-mesh::companion::traffic::FooterSize-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()