263 lines
12 KiB
Python
263 lines
12 KiB
Python
"""Execute CommonCLI get/set routing through the real G3 board and NVS adapter.
|
|
|
|
Only unrelated hardware setting branches are omitted. The production verb
|
|
normalizer, top-level get/set branches, shared FEM handlers and unknown tails
|
|
are extracted verbatim; board methods and checked NVS persistence run whole.
|
|
"""
|
|
from pathlib import Path
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
from cpp_source import body
|
|
from test_replay_reset_integration import extract_braced
|
|
from test_station_g3_slot_settings import BOARD, NVS, VARIANT
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def unknown_tail(method, *, legacy=False):
|
|
final_else = method.rfind("\n } else {")
|
|
if final_else < 0:
|
|
raise AssertionError("review the production unknown-setting branch")
|
|
start = method.rfind("\n#if defined(STATION_G3_ESP32)", 0, final_else)
|
|
if legacy or start < 0 or "_board->handleCommand(" not in method[start:final_else]:
|
|
start = final_else
|
|
return method[start:]
|
|
|
|
|
|
HARNESS = r'''
|
|
#include "nvs.h"
|
|
#include "StationG3Board.h"
|
|
#include <helpers/CLICommandUtils.h>
|
|
#include <cassert>
|
|
#include <cstdlib>
|
|
#include <string>
|
|
#undef assert
|
|
#define assert(condition) do { if (!(condition)) { \
|
|
std::fprintf(stderr, "fixture assertion line %u: %s\n", unsigned(__LINE__), #condition); \
|
|
std::exit(91); } } while (false)
|
|
struct Board : StationG3Board {
|
|
unsigned calls = 0;
|
|
bool handleCommand(const char* command, uint32_t stamp, char* reply) {
|
|
++calls;
|
|
return StationG3Board::handleCommand(command, stamp, reply);
|
|
}
|
|
} board;
|
|
struct Prefs {
|
|
uint8_t radio_fem_rxgain = 1, radio_fem_txgain = 0;
|
|
bool dirty = false;
|
|
bool isDirty() const { return dirty; }
|
|
} prefs;
|
|
struct Callbacks {
|
|
bool tx = false, rx = false;
|
|
unsigned calibrations = 0;
|
|
const Callbacks* getProfileRadio() const { return this; }
|
|
bool isMorseTxActive() const { return tx; }
|
|
bool isMorseRxActive() const { return rx; }
|
|
void recalibrateNoiseFloor() { ++calibrations; }
|
|
} callbacks;
|
|
@KEY_EQUALS@
|
|
struct CommonCLI {
|
|
Board* _board = &board;
|
|
Prefs* _prefs = &prefs;
|
|
Callbacks* _callbacks = &callbacks;
|
|
unsigned saves = 0;
|
|
void savePrefs() { ++saves; prefs.dirty = false; }
|
|
void handleSetCmd(uint32_t sender_timestamp, char* command, char* reply) {
|
|
const char* config = &command[4];
|
|
(void)sender_timestamp;
|
|
@SET_BRANCHES_OPEN@
|
|
@SET_TAIL@
|
|
void handleGetCmd(uint32_t sender_timestamp, char* command, char* reply) {
|
|
const char* config = &command[4];
|
|
(void)sender_timestamp;
|
|
@GET_BRANCHES_OPEN@
|
|
@GET_TAIL@
|
|
void handleCommand(uint32_t sender_timestamp, char* command, char* reply) {
|
|
mesh::cli::normalizeCommandVerb(command);
|
|
@DISPATCH@
|
|
else @TOP_BOARD@
|
|
else { strcpy(reply, "Unknown command"); }
|
|
}
|
|
};
|
|
static void fresh() {
|
|
fake::reset(); fake::resetBoard(); board = Board(); prefs = Prefs(); callbacks = Callbacks();
|
|
station_g3::slotState() = station_g3::SlotState();
|
|
station_g3::loadSlotSettings();
|
|
auto& state = station_g3::slotState();
|
|
state.candidate = state.active = state.last = 2; state.detected = 3;
|
|
}
|
|
static std::string run(CommonCLI& cli, const char* input, uint32_t stamp = 0) {
|
|
char command[160] = {}, reply[160] = {};
|
|
snprintf(command, sizeof(command), "%s", input);
|
|
cli.handleCommand(stamp, command, reply);
|
|
return reply;
|
|
}
|
|
int main(int argc, char** argv) {
|
|
assert(argc == 2); const std::string scenario = argv[1];
|
|
if (scenario == "routing") {
|
|
for (uint32_t stamp : {0U, 123456U}) {
|
|
fresh(); CommonCLI cli;
|
|
prefs.dirty = true;
|
|
auto reply = run(cli, "GET radio.slot", stamp);
|
|
#if defined(STATION_G3_ESP32)
|
|
assert(reply.find("> auto; active=2; detected=1,2") == 0 && "get must reach the real board");
|
|
assert(cli.saves == 0 && prefs.dirty && fake::sets == 0);
|
|
reply = run(cli, "SET radio.slot 1", stamp);
|
|
assert(reply.find("OK - radio.slot 1 saved") == 0 && "set must reach the real board");
|
|
auto& state = station_g3::slotState();
|
|
assert(state.savedMode == 1 && state.bootMode == 0 && state.active == 2 && state.candidate == 2);
|
|
assert(state.last == 2 && fake::disk.at("mode").value == 1 && cli.saves == 1);
|
|
assert(fake::written.size() == 1 && fake::written.front() == "mode");
|
|
reply = run(cli, "get radio.slot", stamp);
|
|
assert(reply.find("reboot required") != std::string::npos && cli.saves == 1);
|
|
assert(run(cli, "set radio.slot auto", stamp).find("OK") == 0);
|
|
assert(run(cli, "set radio.slot 2", stamp).find("OK") == 0);
|
|
state = station_g3::SlotState(); station_g3::loadSlotSettings();
|
|
assert(state.bootMode == 2 && state.savedMode == 2 && state.active == 0);
|
|
#else
|
|
assert(reply.find("unknown") != std::string::npos && board.calls == 0 && cli.saves == 0);
|
|
assert(run(cli, "set radio.slot 1", stamp) == "unknown config");
|
|
assert(fake::sets == 0 && board.calls == 0);
|
|
#endif
|
|
}
|
|
} else if (scenario == "failures") {
|
|
fresh(); CommonCLI cli;
|
|
for (const char* invalid : {"0", "3", "01", "2 extra", "AUTO", ""}) {
|
|
const auto reply = run(cli, (std::string("set radio.slot ") + invalid).c_str());
|
|
assert(reply.find("Error") == 0 && fake::sets == 0);
|
|
}
|
|
for (const char* command : {"set radio.slotting 1", "get radio.slotting", "set nothing 1", "get nothing"}) {
|
|
assert(run(cli, command).find("unknown") != std::string::npos && fake::sets == 0);
|
|
}
|
|
for (unsigned fault = 0; fault < 3; ++fault) {
|
|
fresh();
|
|
fake::openError = fault == 0 ? 9 : 0;
|
|
fake::setError = fault == 1 ? 9 : 0;
|
|
fake::commitError = fault == 2 ? 9 : 0;
|
|
const auto reply = run(cli, "set radio.slot 1", 123);
|
|
assert(reply.find("Error: radio.slot could not be saved") == 0);
|
|
const auto& state = station_g3::slotState();
|
|
assert(state.savedMode == 0 && state.bootMode == 0 && state.active == 2 && state.candidate == 2);
|
|
}
|
|
} else if (scenario == "shared") {
|
|
fresh(); CommonCLI cli;
|
|
for (unsigned morse = 0; morse < 2; ++morse) {
|
|
callbacks.tx = morse == 0; callbacks.rx = morse == 1;
|
|
for (const char* command : {"set radio.fem.rxgain on", "set radio.fem.txgain on"}) {
|
|
const auto reply = run(cli, command);
|
|
assert(reply.find("Error: stop Morse") == 0 && "shared FEM guard must precede the board hook");
|
|
assert(board.calls == 0 && fake::femMutations == 0 && cli.saves == 0);
|
|
}
|
|
}
|
|
callbacks.tx = callbacks.rx = false;
|
|
assert(run(cli, "set radio.fem.rxgain off").find("OK") == 0);
|
|
assert(prefs.radio_fem_rxgain == 0 && cli.saves == 1 && board.calls == 0);
|
|
assert(run(cli, "get radio.fem.rxgain") == "> off" && board.calls == 0);
|
|
assert(run(cli, "set radio.fem.txgain on").find("OK") == 0);
|
|
assert(prefs.radio_fem_txgain == 1 && cli.saves == 2 && board.calls == 0);
|
|
assert(run(cli, "get radio.fem.txgain") == "> on" && board.calls == 0);
|
|
assert(fake::sets == 0);
|
|
} else assert(false);
|
|
}
|
|
'''
|
|
|
|
|
|
class StationG3CliDispatchTest(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
common = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
|
setter = body(common, "void CommonCLI::handleSetCmd(")
|
|
getter = body(common, "void CommonCLI::handleGetCmd(")
|
|
top = body(common, "void CommonCLI::handleCommand(")
|
|
dispatch = "\nelse ".join(extract_braced(top, signature) for signature in (
|
|
'if (memcmp(command, "get ", 4) == 0)',
|
|
'if (memcmp(command, "set ", 4) == 0)'))
|
|
values = {
|
|
"@KEY_EQUALS@": body(common, "static bool configKeyEquals("),
|
|
"@SET_BRANCHES_OPEN@": "\nelse ".join(extract_braced(setter, signature) for signature in (
|
|
'if (memcmp(config, "radio.fem.rxgain ", 17) == 0)',
|
|
'if (memcmp(config, "radio.fem.txgain ", 17) == 0)'))[:-1],
|
|
"@GET_BRANCHES_OPEN@": "\nelse ".join(extract_braced(getter, signature) for signature in (
|
|
'if (configKeyEquals(config, "radio.fem.rxgain"))',
|
|
'if (configKeyEquals(config, "radio.fem.txgain"))'))[:-1],
|
|
"@DISPATCH@": dispatch,
|
|
"@TOP_BOARD@": extract_braced(top, "if (_board->handleCommand("),
|
|
}
|
|
cls.work = tempfile.TemporaryDirectory(prefix="g3-cli-dispatch-")
|
|
work = Path(cls.work.name)
|
|
header = (VARIANT / "StationG3Board.h").read_text()
|
|
board_header = BOARD.replace("@BOOT@", body(header, "void begin()"))
|
|
board_header = board_header.replace("@IRQ@", body(header, "uint32_t getIRQGpio()"))
|
|
(work / "StationG3Board.h").write_text(board_header)
|
|
(work / "StationG3RadioSlot.h").write_text((VARIANT / "StationG3RadioSlot.h").read_text())
|
|
(work / "nvs.h").write_text(NVS)
|
|
(work / "helpers").mkdir()
|
|
(work / "helpers/ESP32GpioHold.h").write_text("#pragma once\n")
|
|
(work / "settings.cpp").write_text((VARIANT / "StationG3RadioSlot.cpp").read_text())
|
|
(work / "board.cpp").write_text((VARIANT / "StationG3Board.cpp").read_text())
|
|
cls.binaries = {}
|
|
for variant in ("production", "old-get", "old-set", "early-board", "other-board"):
|
|
code = HARNESS
|
|
for token, value in values.items():
|
|
code = code.replace(token, value)
|
|
code = code.replace("@SET_TAIL@", unknown_tail(setter, legacy=variant == "old-set"))
|
|
code = code.replace("@GET_TAIL@", unknown_tail(getter, legacy=variant == "old-get"))
|
|
if variant == "early-board":
|
|
code = code.replace(dispatch, 'if (_board->handleCommand(command, sender_timestamp, reply)) {} else ' + dispatch)
|
|
source, binary = work / (variant + ".cpp"), work / variant
|
|
source.write_text(code)
|
|
sanitize = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
|
|
if sys.platform.startswith("linux") else [])
|
|
built = subprocess.run([
|
|
os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", *sanitize,
|
|
*([] if variant == "other-board" else ["-DSTATION_G3_ESP32=1"]),
|
|
"-I" + str(work), "-I" + str(ROOT / "src"), str(work / "settings.cpp"),
|
|
str(work / "board.cpp"), str(source), "-o", str(binary),
|
|
], capture_output=True, text=True, timeout=60)
|
|
if built.returncode:
|
|
cls.work.cleanup()
|
|
raise AssertionError(built.stderr)
|
|
cls.binaries[variant] = binary
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
cls.work.cleanup()
|
|
|
|
def run_case(self, case, variant="production", failure=None):
|
|
result = subprocess.run([str(self.binaries[variant]), case], capture_output=True, text=True, timeout=15)
|
|
if failure:
|
|
self.assertEqual(result.returncode, 91, result.stdout + result.stderr)
|
|
self.assertIn(failure, result.stderr)
|
|
else:
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
|
|
def test_local_and_remote_get_set_reach_checked_board_persistence(self):
|
|
self.run_case("routing")
|
|
|
|
def test_invalid_values_unknown_keys_and_nvs_failures_keep_live_slot(self):
|
|
self.run_case("failures")
|
|
|
|
def test_shared_fem_ownership_and_preference_saving_take_priority(self):
|
|
self.run_case("shared")
|
|
|
|
def test_non_g3_boards_keep_the_previous_unknown_setting_path(self):
|
|
self.run_case("routing", "other-board")
|
|
|
|
def test_old_get_tail_cannot_reach_the_board(self):
|
|
self.run_case("routing", "old-get", "get must reach the real board")
|
|
|
|
def test_old_set_tail_cannot_reach_the_board(self):
|
|
self.run_case("routing", "old-set", "set must reach the real board")
|
|
|
|
def test_early_board_hook_would_bypass_shared_morse_guard(self):
|
|
self.run_case("shared", "early-board", "shared FEM guard must precede the board hook")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|