Files
HaloKeymind/test/test_sdcard_cli_parity.py
T

221 lines
10 KiB
Python

#!/usr/bin/env python3
"""Compile the shared production SD CLI against stateful storage boundaries."""
from pathlib import Path
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
ARDUINO = r'''
#pragma once
#include <stdint.h>
extern uint32_t clock_ms;
inline uint32_t millis() { return clock_ms; }
'''
BOARD = '#pragma once\nnamespace mesh { class MainBoard {}; }\n'
CONTEXT = r'''
#pragma once
#include <MeshCore.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
namespace mesh { namespace ota {
inline bool ethernet_owned = false;
inline bool rak4631_ethernet_owns_spi() { return ethernet_owned; }
struct OtaManager {
enum State { IDLE, FETCHING, COMPLETE };
State state = IDLE;
unsigned resets = 0;
State fetchState() const { return state; }
void reset_session() { ++resets; state = IDLE; }
void want(unsigned) {}
void want_mid(const uint8_t*) {}
};
struct FetchStore {
bool internal = false, sd = true;
uint32_t staged = 0;
bool usesInternal() const { return internal; }
bool usesSd() const { return sd; }
uint32_t staged_size() const { return staged; }
};
struct OtaStoreSdNrf52 {
unsigned formats = 0, erases = 0, spaces = 0, lists = 0;
uint16_t page = 0;
char error[120] = "card unavailable";
bool format_ok = true, erase_ok = true, read_ok = true;
bool formatCard(MainBoard&) { ++formats; strcpy(error, "card unavailable"); return format_ok; }
bool eraseCard(MainBoard&) { ++erases; strcpy(error, "card unavailable"); return erase_ok; }
bool getSpace(MainBoard&, uint64_t& used, uint64_t& free) {
++spaces; used = 12; free = 34; return read_ok;
}
bool listFiles(MainBoard&, uint16_t p, char* reply, size_t cap) {
++lists; page = p; snprintf(reply, cap, "> files page=%u", unsigned(p)); return read_ok;
}
const char* last_error() const { return error; }
};
struct OtaContext {
OtaManager manager;
FetchStore fetch_store;
OtaStoreSdNrf52 card;
bool apply_pending = false;
uint32_t session_started_ms = 1;
OtaManager::State prev_fstate = OtaManager::FETCHING;
unsigned prepares = 0, finishes = 0;
OtaStoreSdNrf52& sdStagingStore() { return card; }
void prepareSdCardReset() { ++prepares; }
void finishSdCardReset(uint32_t) { ++finishes; strcpy(card.error, "cleanup replaced error"); }
};
inline OtaContext context;
inline bool acquired = false, out_of_memory = false;
inline unsigned acquisitions = 0, accesses = 0;
inline bool ota_acquire_context(char* reply, size_t cap) {
++acquisitions;
if (out_of_memory) { snprintf(reply, cap, "ERR mOTA out of memory"); return false; }
acquired = true; return true;
}
inline OtaContext& ota_ctx() {
if (!acquired) __builtin_trap();
++accesses; return context;
}
} }
'''
HARNESS = r'''
#include <helpers/SdCardCLI.h>
#include <helpers/ota/OtaContext.h>
#include <cassert>
#include <cstring>
#include <initializer_list>
uint32_t clock_ms = 500000;
int main() {
using namespace mesh::ota;
mesh::MainBoard board;
mesh::cli::SdCardCLIState state;
struct Reply { char text[160]; char sentinel[8] = "guarded"; } reply{};
auto command = [&](const char* value) {
bool handled = mesh::cli::handleSdCardCommand(state, board, value, reply.text, sizeof(reply.text));
assert(strcmp(reply.sentinel, "guarded") == 0); return handled;
};
assert(!command("get sdcardoops"));
assert(!command("set unrelated"));
for (const char* value : {"get sdcard", "get sdcard *", "get sdcard format", "get sdcard erase"}) {
assert(command(value)); assert(strstr(reply.text, "never"));
}
for (const char* value : {"get sdcard invalid", "get sdcard ls 0", "get sdcard dir -1",
"get sdcard dir 65536", "get sdcard lsoops", "get sdcard dir 2 extra",
"set sdcard format unknown"}) {
assert(command(value)); assert(strncmp(reply.text, "Error:", 6) == 0);
}
assert(!acquisitions && !accesses);
#if defined(OTA_RAK15002_SD_STORE)
ethernet_owned = true;
for (const char* value : {"get sdcard free", "get sdcard ls", "set sdcard format --force", "set sdcard erase --force"}) {
assert(command(value)); assert(strstr(reply.text, "Ethernet off"));
assert(!acquisitions && !accesses && !context.prepares && !context.manager.resets);
}
ethernet_owned = false;
#endif
out_of_memory = true;
assert(command("get sdcard free")); assert(strstr(reply.text, "out of memory"));
assert(!accesses && !context.card.spaces);
out_of_memory = false;
assert(command("get sdcard free")); assert(strstr(reply.text, "12 B") && strstr(reply.text, "34 B"));
assert(command("get sdcard dir 65535")); assert(context.card.page == 65535);
context.card.read_ok = false;
assert(command("get sdcard free")); assert(strstr(reply.text, "card unavailable"));
assert(command("get sdcard ls")); assert(strstr(reply.text, "card unavailable"));
context.card.read_ok = true;
context.apply_pending = true;
assert(command("set sdcard format --force"));
assert(strstr(reply.text, "apply is pending") && !context.prepares && !context.card.formats);
context.apply_pending = false;
context.card.format_ok = false;
for (const char* value : {"set sdcard formats", "set sdcard eraseX",
"set sdcard format--force", "set sdcard erase --force",
"set sdcard format\t--force", "set sdcard erase --force "}) {
assert(command(value));
assert(strcmp(reply.text, "Error: use set sdcard format|erase [--force]") == 0);
assert(context.card.formats == 0 && context.card.erases == 0 && context.prepares == 0);
}
assert(command("set sdcard format"));
assert(strstr(reply.text, "format failed") && context.prepares == 1 && context.finishes == 1);
assert(strcmp(reply.text, "Error: SD card format failed: card unavailable") == 0);
assert(!state.format_recorded);
context.card.format_ok = true;
assert(command("set sdcard format")); assert(state.format_recorded && state.format_at == clock_ms);
unsigned formats = context.card.formats;
assert(command("set sdcard format")); assert(strstr(reply.text, "--force") && context.card.formats == formats);
assert(command("set sdcard format --force")); assert(context.card.formats == formats + 1);
context.card.erase_ok = false;
assert(command("set sdcard erase")); assert(strstr(reply.text, "erase failed"));
assert(strcmp(reply.text, "Error: SD card erase failed: card unavailable") == 0);
assert(!state.erase_recorded && context.prepares == context.finishes);
context.card.erase_ok = true; context.card.format_ok = false;
assert(command("set sdcard erase")); assert(strstr(reply.text, "erased but format failed"));
assert(strcmp(reply.text, "Error: SD card erased but format failed: card unavailable") == 0);
assert(state.erase_recorded && context.prepares == context.finishes);
context.card.format_ok = true;
assert(command("set sdcard erase --force")); assert(strstr(reply.text, "erase and format complete"));
// uint32_t wrap must retain the same five-minute cooldown semantics.
state.format_recorded = true; state.format_at = 0xfffffff0u; clock_ms = 20;
formats = context.card.formats;
assert(command("set sdcard format")); assert(context.card.formats == formats);
clock_ms += 300000;
assert(command("set sdcard format")); assert(context.card.formats == formats + 1);
// Short framed replies are always bounded, including failed operations.
struct Tiny { char text[8]; char sentinel[8] = "guarded"; } tiny{};
assert(mesh::cli::handleSdCardCommand(state, board, "get sdcard *", tiny.text, sizeof(tiny.text)));
assert(tiny.text[7] == 0 && strcmp(tiny.sentinel, "guarded") == 0);
assert(mesh::cli::handleSdCardCommand(state, board, "set sdcard format --force", tiny.text, sizeof(tiny.text)));
assert(tiny.text[7] == 0 && strcmp(tiny.sentinel, "guarded") == 0);
assert(mesh::cli::handleSdCardCommand(state, board, "set sdcard erase --force", tiny.text, sizeof(tiny.text)));
assert(tiny.text[7] == 0 && strcmp(tiny.sentinel, "guarded") == 0);
assert(!mesh::cli::handleSdCardCommand(state, board, nullptr, reply.text, sizeof(reply.text)));
assert(!mesh::cli::handleSdCardCommand(state, board, "get sdcard", nullptr, 0));
}
'''
class SdCardCLIParityTests(unittest.TestCase):
def compile_and_run(self, defines):
compiler = os.environ.get('CXX') or shutil.which('g++') or shutil.which('clang++')
if not compiler:
self.skipTest('host C++17 compiler required')
with tempfile.TemporaryDirectory(prefix='sdcard-cli-') as folder:
work = Path(folder)
for name, text in {'Arduino.h': ARDUINO, 'MeshCore.h': BOARD,
'helpers/ota/OtaContext.h': CONTEXT,
'main.cpp': HARNESS}.items():
path = work / name; path.parent.mkdir(parents=True, exist_ok=True); path.write_text(text)
sanitizer = ['-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie'] if sys.platform.startswith('linux') else []
result = subprocess.run([compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror',
*sanitizer, *['-D' + value for value in ['NRF52_PLATFORM', 'OTA_SD_STORE', *defines]],
'-I' + str(work), '-I' + str(ROOT / 'src'), str(ROOT / 'src/helpers/SdCardCLI.cpp'),
str(work / 'main.cpp'), '-o', str(work / 'test')], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
result = subprocess.run([str(work / 'test')], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_actual_shared_cpp_handles_legacy_sd_and_source_only_profiles(self):
for defines in ([], ['OTA_SEEDER_ONLY'], ['OTA_TOWER_AUTO_STORE'],
['OTA_RAK15002_SD_STORE', 'OTA_RAK_AUTO_STORE'],
['OTA_RAK15002_SD_STORE', 'OTA_SEEDER_ONLY']):
with self.subTest(defines=defines):
self.compile_and_run(defines)
def test_common_cli_delegates_to_same_implementation(self):
source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text()
self.assertIn('handleSdCardSet(_sdcard_cli, *_board, config, reply, 160)', source)
self.assertIn('handleSdCardGet(_sdcard_cli, *_board, config, reply, 160)', source)
self.assertNotIn('context.prepareSdCardReset()', source)
header = (ROOT / 'src/helpers/CommonCLI.h').read_text()
self.assertIn('mesh::cli::SdCardCLIState _sdcard_cli;', header)
if __name__ == '__main__':
unittest.main()