Files
MeshCore/test/test_companion_prefs_image.py
T
mikecarper 54a5137032 Recover unreadable contacts without stranding Companions
Verify fallback namespace moves before accepting publication or rollback. Reload readable contact images at a safe host boundary, discard only unreadable contact data after bounded retries, and permit volatile operation when storage cannot recover. Preserve unrelated stores and fail explicit durability requests truthfully.

Keep existing flash, RAM, OTA and capability limits by streaming nRF52 preferences through the validated wire table and sharing identical contact deadline resets. Add fault-injection, sanitizer, legacy-image parity and CLI lifecycle regressions.
2026-10-16 08:28:45 -07:00

510 lines
23 KiB
Python

"""Check explicit preference bytes, STM32 staging and nRF field streaming."""
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]
# Published field order and sizes, independent of the implementation's emitter
# and the class's in-memory member order or padding. None represents zero pad.
WIRE_FIELDS = (
('airtime_factor', 4), ('node_name', 32), (None, 4),
('@lat', 8), ('@lon', 8), ('freq', 4), ('sf', 1), ('cr', 1),
('client_repeat', 1), ('manual_add_contacts', 1), ('bw', 4),
('tx_power_dbm', 1), ('telemetry_mode_base', 1), ('telemetry_mode_loc', 1),
('telemetry_mode_env', 1), ('rx_delay_base', 4), ('advert_loc_policy', 1),
('multi_acks', 1), ('path_hash_mode', 1), (None, 1), ('ble_pin', 4),
('buzzer_quiet', 1), ('gps_enabled', 1), ('gps_interval', 4),
('autoadd_config', 1), ('autoadd_max_hops', 1), ('rx_boosted_gain', 1),
('default_scope_name', 31), ('default_scope_key', 16),
('radio_fem_rxgain', 1), ('radio_fem_rxgain_override', 1),
('vibe_quiet', 1), ('radio_fem_txgain', 1), ('rx_powersaving_enabled', 1),
('rx_ps_rx_us', 4), ('rx_ps_sleep_us', 4), ('rx_ps_level', 1),
('rx_ps_preamble', 1), ('powersaving_enabled', 1), ('wifi_enabled', 1),
('powersaving_policy_version', 1), ('usb_logging_enabled', 1),
('bluetooth_name', 32), ('display_rotation_degrees', 2), ('cad_enabled', 1),
('cad_scan_timeout_ms', 2), ('cad_retry_delay_ms', 2),
('cad_max_duration_ms', 2), ('bluetooth_mac_mode', 1), ('bluetooth_mac', 6),
('bluetooth_stealth_peer_type', 1), ('bluetooth_stealth_peer', 6),
('bluetooth_stealth_mode', 1), ('tx_delay_factor', 4),
('direct_tx_delay_factor', 4), ('interference_threshold', 1),
('agc_reset_interval', 1), ('tz_offset', 1),
)
TAIL_FIELDS = (
('flood_retry_attempts', 1), ('flood_retry_max_path', 1),
('flood_retry_group_max_path', 1), ('flood_retry_advert_enabled', 1),
('one_key_dm_enabled', 1), ('bluetooth_enabled', 1),
('gps_sync_interval_hours', 2), ('usb_debug_enabled', 1),
('lost_reply', 1),
)
OPTIONAL_FIELDS = (
('defined(TBEAM_1W)', (('fan_mode', 5), ('fan_lo', 1), ('fan_hi', 1))),
('defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)',
(('wifi_ssid', 33), ('wifi_pwd', 64))),
)
TESTS = r'''
#include <algorithm>
#include <vector>
static void seed(CompanionNodePrefs& prefs, uint32_t& random) {
auto fill = [&](void* destination, size_t size) {
auto* bytes = static_cast<uint8_t*>(destination);
for (size_t i = 0; i < size; ++i) {
random = random * 1664525u + 1013904223u;
bytes[i] = static_cast<uint8_t>(random >> 24);
}
};
@SEED@
prefs.tx_media = random % 5;
prefs.tx_ssh = (random >> 8) % 5;
#if defined(RAK_4631)
prefs.peripherals_shared = uint8_t(random >> 16);
#endif
}
static std::vector<uint8_t> golden(const CompanionNodePrefs& prefs,
double lat, double lon) {
std::vector<uint8_t> bytes;
auto append = [&](const void* data, size_t size) {
const auto* first = static_cast<const uint8_t*>(data);
bytes.insert(bytes.end(), first, first + size);
};
@GOLDEN@
#if defined(TBEAM_1W)
assert(bytes.size() == 243);
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
assert(bytes.size() == 333);
#else
assert(bytes.size() == 236);
#endif
#if defined(RAK_4631)
bytes.insert(bytes.end(), {'P', 'M', 'O', 1,
uint8_t(prefs.peripherals_shared == 0 ? 0 : 1), 0});
uint16_t peripheral_crc = 0xffff;
for (size_t i = bytes.size() - 6; i < bytes.size(); ++i) {
peripheral_crc ^= uint16_t(bytes[i]) << 8;
for (unsigned bit = 0; bit < 8; ++bit)
peripheral_crc = (peripheral_crc & 0x8000)
? uint16_t((peripheral_crc << 1) ^ 0x1021) : uint16_t(peripheral_crc << 1);
}
bytes.push_back(uint8_t(peripheral_crc >> 8)); bytes.push_back(uint8_t(peripheral_crc));
#endif
// Independent wire definition of the new checked traffic footer. Keep
// every historical byte above unchanged and encode the public policy tags.
bytes.insert(bytes.end(), {'T', 'X', 'R', 1,
uint8_t(prefs.tx_media ? 0xa0 | prefs.tx_media : 0),
uint8_t(prefs.tx_ssh ? 0xa0 | prefs.tx_ssh : 0)});
uint16_t crc = 0xffff;
for(size_t i=bytes.size()-6;i<bytes.size();++i){
crc ^= uint16_t(bytes[i]) << 8;
for(unsigned bit=0;bit<8;++bit)
crc = (crc&0x8000) ? uint16_t((crc<<1)^0x1021) : uint16_t(crc<<1);
}
bytes.push_back(uint8_t(crc>>8));bytes.push_back(uint8_t(crc));
return bytes;
}
static void unchanged(const DataStore& store,
const std::vector<uint8_t>& before) {
assert(store.fs.files.at("/new_prefs") == before);
assert(!store.fs.exists("/new_prefs.tmp"));
}
int main() {
uint32_t random = 0x31415926;
unsigned comparisons = 0;
for (unsigned trial = 0; trial < 512; ++trial) {
CompanionNodePrefs prefs;
// Exercise every persisted byte, not a memcpy of the nontrivial class.
// Raw invalid values are intentionally retained by the writer; validation
// and migration remain the loader/CLI's existing responsibility.
seed(prefs, random);
DataStore store;
WRITE_CALLS.clear();
assert(store.savePrefs(prefs, 47.125, -122.25));
assert(store.fs.files.at("/new_prefs") == golden(prefs, 47.125, -122.25));
assert(!store.fs.exists("/new_prefs.tmp"));
#if defined(NRF52_PLATFORM)
const std::vector<size_t> streaming = {@WRITE_SIZES@
#if defined(RAK_4631)
, 8
#endif
, 8};
assert(WRITE_CALLS == streaming); // Direct fields, never a 236-byte staging write.
#if defined(RAK_4631)
assert(store.fs.files.at("/new_prefs").size() == 252);
#endif
#endif
DataStore reference;
assert(reference.savePrefsLegacy(prefs, 47.125, -122.25));
assert(reference.fs.files.at("/new_prefs") == store.fs.files.at("/new_prefs"));
++comparisons;
}
CompanionNodePrefs original;
assert(original.lost_reply == 0);
assert(original.tx_media == mesh::RADIO_TX_AUTO && original.tx_ssh == mesh::RADIO_TX_AUTO);
strcpy(original.node_name, "durable image");
original.freq = 915.25f; original.ble_pin = 123456;
original.bluetooth_enabled = 1; original.usb_debug_enabled = 1;
original.gps_sync_interval_hours = 336;
original.lost_reply = 2;
original.tx_media = mesh::RADIO_TX_SECONDARY;
original.tx_ssh = mesh::RADIO_TX_OFF;
#if defined(RAK_4631)
original.peripherals_shared = 1;
#endif
DataStore initial;
assert(initial.savePrefs(original, 47.125, -122.25));
const auto before = golden(original, 47.125, -122.25);
const size_t historical_size=before.size()-mesh::companion::traffic::FooterSize
#if defined(RAK_4631)
- mesh::peripherals::FooterSize
#endif
;
assert(initial.fs.files.at("/new_prefs") == before);
// Every complete historical wire boundary stays readable, and an old
// image always disables the new opt-in response even if live RAM was on.
const std::vector<size_t> legacy_sizes = {
84, 85, 90, 91, 92, 93, 140, 141, 142, 143, 144, 155,
156, 157, 158, 159, 191, 193, 200, 207, 214, 215, 226,
#if defined(TBEAM_1W)
// The generic 231-byte compatibility boundary also ends at TBeam's
// complete fan-mode string; preserve that existing accepted image.
231, 233, 237, 238, 239, 241, 242
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
323, 327, 328, 329, 331, 332
#else
230, 231, 232, 234, 235
#endif
};
for (size_t length : legacy_sizes) {
DataStore reboot;
auto historical = before; historical.resize(length);
reboot.fs.files["/new_prefs"] = historical;
CompanionNodePrefs live; live.lost_reply = 2;
live.tx_media=mesh::RADIO_TX_BOTH;live.tx_ssh=mesh::RADIO_TX_PRIMARY;
live.getCustom()->setByKey("local_tag", "unchanged");
double lat = 1, lon = 2;
assert(reboot.loadPrefsInt("/new_prefs", live, lat, lon));
assert(live.lost_reply == 0 && live.ble_pin == original.ble_pin);
assert(live.tx_media==mesh::RADIO_TX_AUTO&&live.tx_ssh==mesh::RADIO_TX_AUTO);
assert(live.freq == original.freq && !strcmp(live.node_name, original.node_name));
assert(lat == 47.125 && lon == -122.25);
assert(reboot.fs.files.at("/new_prefs") == historical);
char tag[16] = {};
assert(live.getCustom()->getByKey("local_tag", tag, sizeof(tag)));
assert(!strcmp(tag, "unchanged"));
++comparisons;
}
// The accepted-size table is an exact allowlist. Check every length through
// a full extra byte range, including sizes that could alias legacy values
// if the underlying file size were accidentally narrowed with the table.
std::vector<size_t> accepted_sizes = legacy_sizes;
accepted_sizes.push_back(historical_size);
#if defined(RAK_4631)
accepted_sizes.push_back(historical_size + mesh::peripherals::FooterSize);
#endif
accepted_sizes.push_back(before.size());
for (size_t length = 0; length <= before.size() + 256; ++length) {
DataStore reboot;
auto candidate = before;
candidate.resize(length);
reboot.fs.files["/new_prefs"] = candidate;
CompanionNodePrefs live;
live.freq = 920.5f;
live.lost_reply = 1;
live.getCustom()->setByKey("length_tag", "unchanged");
double lat = 1, lon = 2;
const bool expected = std::find(accepted_sizes.begin(), accepted_sizes.end(),
length) != accepted_sizes.end();
assert(reboot.loadPrefsInt("/new_prefs", live, lat, lon) == expected);
if (expected) {
assert(live.freq == original.freq);
assert(live.lost_reply == (length >= historical_size ? 2 : 0));
assert(live.tx_media == (length == before.size() ? mesh::RADIO_TX_SECONDARY : mesh::RADIO_TX_AUTO));
assert(live.tx_ssh == (length == before.size() ? mesh::RADIO_TX_OFF : mesh::RADIO_TX_AUTO));
#if defined(RAK_4631)
assert(live.peripherals_shared == (length >= 244 ? 1 : 0));
#endif
assert(lat == 47.125 && lon == -122.25);
} else {
assert(live.freq == 920.5f && live.lost_reply == 1);
assert(lat == 1 && lon == 2);
}
char tag[16] = {};
assert(live.getCustom()->getByKey("length_tag", tag, sizeof(tag)));
assert(!strcmp(tag, "unchanged"));
assert(reboot.fs.files.at("/new_prefs") == candidate);
++comparisons;
}
// All 256 byte encodings are preserved by the append-only serializer.
// Only off/no/yes are accepted on reboot; corrupt values normalize off
// without erasing radio settings or changing the committed image.
for (unsigned value = 0; value < 256; ++value) {
CompanionNodePrefs saved;
assert(saved.copyPersistedValuesFrom(original));
saved.lost_reply = static_cast<uint8_t>(value);
DataStore writer;
assert(writer.savePrefs(saved, 47.125, -122.25));
const auto disk = writer.fs.files.at("/new_prefs");
assert(disk[historical_size-1] == value);
assert(std::equal(disk.begin(), disk.begin()+historical_size-1, before.begin()));
assert(std::equal(disk.begin()+historical_size, disk.end(), before.begin()+historical_size));
DataStore reboot; reboot.fs.files["/new_prefs"] = disk;
CompanionNodePrefs live; live.lost_reply = 2;
double lat = 1, lon = 2;
assert(reboot.loadPrefsInt("/new_prefs", live, lat, lon));
assert(live.lost_reply == (value <= 2 ? value : 0));
assert(live.ble_pin == original.ble_pin && live.freq == original.freq);
assert(live.gps_sync_interval_hours == 336 && live.usb_debug_enabled == 1);
assert(lat == 47.125 && lon == -122.25);
assert(reboot.fs.files.at("/new_prefs") == disk);
++comparisons;
}
{
DataStore torn; torn.fs.files["/new_prefs"] = before;
torn.fs.fail_read_after = static_cast<int>(before.size() - 1);
CompanionNodePrefs live; live.lost_reply = 1; live.freq = 920.5f;
strcpy(live.node_name, "live unchanged");
double lat = 1, lon = 2;
assert(!torn.loadPrefsInt("/new_prefs", live, lat, lon));
assert(live.lost_reply == 1 && live.freq == 920.5f);
assert(!strcmp(live.node_name, "live unchanged") && lat == 1 && lon == 2);
assert(torn.fs.files.at("/new_prefs") == before);
++comparisons;
}
CompanionNodePrefs changed;
assert(changed.copyPersistedValuesFrom(original));
strcpy(changed.node_name, "replacement image");
changed.freq = 910.5f; changed.usb_debug_enabled = 0;
changed.gps_sync_interval_hours = 24;
changed.lost_reply = 1;
const auto after = golden(changed, 42.25, -121.125);
// Every possible short write and readback, including every tail byte,
// must preserve the old durable image. STM32 issues only one image write;
// the other supported platforms preserve their scalar-write path.
for (size_t quota = 0; quota < before.size(); ++quota) {
DataStore write;
write.fs.files["/new_prefs"] = before;
write.fs.fail_write_after = static_cast<int>(quota);
assert(!write.savePrefs(changed, 42.25, -121.125));
unchanged(write, before);
DataStore reference;
reference.fs.files["/new_prefs"] = before;
reference.fs.fail_write_after = static_cast<int>(quota);
assert(!reference.savePrefsLegacy(changed, 42.25, -121.125));
unchanged(reference, before);
write.fs.fail_write_after = -1;
assert(write.savePrefs(changed, 42.25, -121.125));
assert(write.fs.files.at("/new_prefs") == after);
DataStore read;
read.fs.files["/new_prefs"] = before;
read.fs.fail_read_after = static_cast<int>(quota);
assert(!read.savePrefs(changed, 42.25, -121.125));
unchanged(read, before);
++comparisons;
}
#if defined(RAK_4631)
// All checked peripheral-footer bits are validation inputs, including the
// boundary immediately before the independent traffic footer.
for (unsigned bit = 0; bit < 64; ++bit) {
DataStore corrupt;
auto candidate = before;
candidate[236 + bit / 8] ^= uint8_t(1u << (bit % 8));
corrupt.fs.files["/new_prefs"] = candidate;
CompanionNodePrefs live; live.freq = 920.5f; live.peripherals_shared = 0;
double lat = 1, lon = 2;
assert(!corrupt.loadPrefsInt("/new_prefs", live, lat, lon));
assert(live.freq == 920.5f && live.peripherals_shared == 0 && lat == 1 && lon == 2);
assert(corrupt.fs.files.at("/new_prefs") == candidate);
++comparisons;
}
#endif
for (unsigned fault = 0; fault < 3; ++fault) {
DataStore store;
store.fs.files["/new_prefs"] = before;
if (fault == 0) store.fs.fail_write_open = true;
if (fault == 1) store.fs.fail_write = true;
if (fault == 2) store.fs.fail_read_open = true;
assert(!store.savePrefs(changed, 42.25, -121.125));
unchanged(store, before);
++comparisons;
}
#if defined(STM32_PLATFORM) || defined(NRF52_PLATFORM)
const unsigned rename_steps = 1;
#else
const unsigned rename_steps = 2;
#endif
for (unsigned failure = 1; failure <= rename_steps; ++failure) {
DataStore store;
store.fs.files["/new_prefs"] = before;
store.fs.fail_rename = static_cast<int>(failure);
assert(!store.savePrefs(changed, 42.25, -121.125));
unchanged(store, before);
++comparisons;
}
assert(initial.savePrefs(changed, 42.25, -121.125));
CompanionNodePrefs loaded;
double lat = 0, lon = 0;
assert(initial.loadPrefs(loaded, lat, lon));
assert(!strcmp(loaded.node_name, changed.node_name));
assert(loaded.freq == changed.freq && loaded.ble_pin == changed.ble_pin);
assert(loaded.gps_sync_interval_hours == 24 && loaded.usb_debug_enabled == 0);
assert(loaded.lost_reply == 1);
assert(loaded.tx_media == original.tx_media && loaded.tx_ssh == original.tx_ssh);
assert(lat == 42.25 && lon == -121.125);
#if defined(STM32_PLATFORM)
// A future field or wrong staging capacity must fail closed, not overrun
// the stack or silently publish a truncated/extra-byte format.
for (bool undersized : {false, true}) {
DataStore store;
store.fs.files["/new_prefs"] = before;
assert(!(undersized ? store.savePrefsTooSmall(changed, lat, lon)
: store.savePrefsTooLarge(changed, lat, lon)));
unchanged(store, before);
++comparisons;
}
#endif
printf("PASS: %u independent wire/fault comparisons\n", comparisons);
}
'''
def field_code(rows, seed=False):
result = []
for name, size in rows:
if seed:
if name is not None and not name.startswith('@'):
result.append(f'fill(&prefs.{name}, sizeof(prefs.{name}));')
elif name is None:
result.append(f'bytes.insert(bytes.end(), {size}, uint8_t(0));')
else:
value = name[1:] if name.startswith('@') else 'prefs.' + name
result.append(f'static_assert(sizeof({value}) == {size}, "published field size");')
result.append(f'append(&{value}, {size});')
return '\n '.join(result)
class CompanionPrefsImageTests(unittest.TestCase):
def test_explicit_wire_layout_and_atomic_staging(self):
source = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text()
save = method(source, 'bool DataStore::savePrefs(')
nrf_branch_start = save.index('#elif defined(NRF52_PLATFORM) && defined(__GNUC__)')
nrf_branch = save[nrf_branch_start:save.index('\n#else', nrf_branch_start)]
self.assertIn('file.write(', nrf_branch)
self.assertIn('prefValue(', nrf_branch)
self.assertNotIn('image[', nrf_branch)
self.assertNotIn('memcpy', nrf_branch)
legacy_writer = (ROOT / 'test/fixtures/companion_prefs_storage/legacy_writer.inc').read_text()
commit = ' success = file.commit(success);'
self.assertEqual(legacy_writer.count(commit), 1)
legacy_writer = legacy_writer.replace(commit, '''#if defined(RAK_4631)
uint8_t peripheral_footer[8];
mesh::peripherals::encodeFooter(peripheral_footer, _prefs.peripherals_shared);
success = success && file.write(peripheral_footer, sizeof(peripheral_footer)) == sizeof(peripheral_footer);
#endif
uint8_t traffic_footer[8];
mesh::companion::traffic::encodeFooter(traffic_footer, _prefs.tx_media, _prefs.tx_ssh);
success = success && file.write(traffic_footer, sizeof(traffic_footer)) == sizeof(traffic_footer);
''' + commit)
methods = esp_recovery_helpers(source) + '\n'.join(
method(source, signature) for signature in (
'bool DataStore::loadPrefs(', 'bool DataStore::loadPrefsInt(')
) + '\n' + save + '\n' + legacy_writer
for suffix, capacity in (('TooSmall', 235), ('TooLarge', 237)):
variant = save.replace('DataStore::savePrefs(', 'DataStore::savePrefs' + suffix + '(', 1)
self.assertIn('uint8_t image[236];', variant)
variant = variant.replace('uint8_t image[236];', f'uint8_t image[{capacity}];', 1)
methods += '\n#if defined(STM32_PLATFORM)\n' + variant + '\n#endif\n'
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);\n'
' bool savePrefsTooSmall(const CompanionNodePrefs&, double, double);\n'
' bool savePrefsTooLarge(const CompanionNodePrefs&, double, double);')
self.assertEqual(sum(size for _, size in WIRE_FIELDS), 226)
self.assertEqual(sum(size for _, size in TAIL_FIELDS), 10)
generated = {}
for seed in (False, True):
rows = [field_code(WIRE_FIELDS, seed)]
for condition, fields in OPTIONAL_FIELDS:
rows.append('#if ' + condition + '\n' + field_code(fields, seed) + '\n#endif')
rows.append(field_code(TAIL_FIELDS, seed))
generated['@SEED@' if seed else '@GOLDEN@'] = '\n '.join(rows)
tests = TESTS
for placeholder, value in generated.items():
tests = tests.replace(placeholder, value)
tests = tests.replace('@WRITE_SIZES@', ', '.join(
str(size) for _, size in WIRE_FIELDS + TAIL_FIELDS))
with tempfile.TemporaryDirectory(prefix='meshcore-prefs-image-') 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>'))
# Reuse the existing memory-filesystem boundary, adding only an
# observable open failure needed to exercise the actual writer.
fs = (ROOT / 'test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h').read_text()
fs = fs.replace('class MemoryFS;',
'static std::vector<size_t> WRITE_CALLS;\nclass MemoryFS;')
fs = fs.replace('inline size_t File::write(const uint8_t* data, size_t size) {',
'inline size_t File::write(const uint8_t* data, size_t size) {\n'
' WRITE_CALLS.push_back(size);')
fs = fs.replace(' bool fail_write = false;',
' bool fail_write = false, fail_write_open = false;')
fs = fs.replace(' if (*mode == \'w\') files[path].clear();',
' if (*mode == \'w\' && fail_write_open) return {};\n'
' if (*mode == \'w\') files[path].clear();')
(work / 'helpers').mkdir()
(work / 'helpers/IdentityStore.h').write_text(fs)
cpp = work / 'test.cpp'
cpp.write_text(code + tests)
for platform in ('STM32_PLATFORM', 'NRF52_PLATFORM', 'NRF52_PLATFORM,RAK_4631', '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()