268 lines
11 KiB
Python
268 lines
11 KiB
Python
"""Execute current common-prefs reads against genuine compact stock writers."""
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
|
|
from test_replay_reset_integration import extract_braced
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
FIXTURES = ROOT / "test/fixtures/common_prefs_rxgain"
|
|
|
|
HARNESS = r'''
|
|
#include <algorithm>
|
|
#include <cassert>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <vector>
|
|
#define MESH_DEBUG_PRINTLN(...) ((void)0)
|
|
namespace mesh { void hilStartupTrace(const char*) {} }
|
|
template<typename T> T constrain(T n, int lo, int hi) {
|
|
return std::max(T(lo), std::min(n, T(hi)));
|
|
}
|
|
struct NodePrefs { @FIELDS@ };
|
|
struct File {
|
|
std::vector<uint8_t> bytes;
|
|
size_t cursor = 0, reads = 0;
|
|
size_t size() const { return bytes.size(); }
|
|
size_t available() const { return bytes.size() - cursor; }
|
|
size_t read(uint8_t* out, size_t n) {
|
|
++reads;
|
|
n = std::min(n, available());
|
|
if (n) memcpy(out, bytes.data() + cursor, n);
|
|
cursor += n;
|
|
return n;
|
|
}
|
|
size_t write(const uint8_t* in, size_t n) {
|
|
bytes.insert(bytes.end(), in, in + n);
|
|
return n;
|
|
}
|
|
void close() {}
|
|
};
|
|
@CONSTANTS@
|
|
struct CommonCLI {
|
|
NodePrefs prefs{};
|
|
NodePrefs* _prefs = &prefs;
|
|
bool _com_prefs_needs_upgrade = false;
|
|
void load(File& file) {
|
|
const char* filename = "/com_prefs";
|
|
(void)filename;
|
|
@CORE_READER@
|
|
@GAIN_TAIL@
|
|
@GAIN_CONSTRAINT@
|
|
}
|
|
File stock1141() { File file; @STOCK_1141@ return file; }
|
|
File stock1140() { File file; @STOCK_1140@ return file; }
|
|
File modern() { File file; @MODERN_WRITER@ return file; }
|
|
};
|
|
CommonCLI stock(uint8_t gain) {
|
|
CommonCLI writer;
|
|
writer.prefs.rx_boosted_gain = gain;
|
|
writer.prefs.freq = 910.0f;
|
|
writer.prefs.bw = 62.5f;
|
|
writer.prefs.bridge_baud = 115200;
|
|
strcpy(writer.prefs.node_name, "synthetic legacy fixture");
|
|
return writer;
|
|
}
|
|
void checkLoaded(File image, uint8_t board_default, uint8_t expected) {
|
|
CommonCLI reader;
|
|
reader.prefs.rx_boosted_gain = board_default;
|
|
reader.load(image);
|
|
assert(reader.prefs.rx_boosted_gain == expected);
|
|
assert(reader.prefs.freq == 910.0f && reader.prefs.bw == 62.5f);
|
|
assert(reader.prefs.bridge_baud == 115200);
|
|
assert(!strcmp(reader.prefs.node_name, "synthetic legacy fixture"));
|
|
}
|
|
int main(int argc, char** argv) {
|
|
assert(argc == 2);
|
|
const std::string scenario = argv[1];
|
|
if (scenario == "stock-on") {
|
|
auto image = stock(1).stock1141();
|
|
assert(image.size() == 290 && image.bytes[79] == 1);
|
|
checkLoaded(image, 0, 1); // G2's newer default must not discard saved on.
|
|
checkLoaded(image, 1, 1);
|
|
} else if (scenario == "stock-off-padding") {
|
|
auto off = stock(0).stock1141();
|
|
auto padding = stock(1).stock1140();
|
|
assert(off.size() == 290 && off.bytes[79] == 0);
|
|
assert(padding.bytes == off.bytes); // No marker distinguishes old padding.
|
|
checkLoaded(off, 0, 0);
|
|
checkLoaded(off, 1, 1); // Ambiguous zero retains the existing board default.
|
|
checkLoaded(padding, 0, 0);
|
|
checkLoaded(padding, 1, 1);
|
|
} else if (scenario == "malformed-truncated") {
|
|
for (uint8_t invalid : {2, 0xff}) {
|
|
auto image = stock(invalid).stock1141();
|
|
for (uint8_t board_default : {0, 1})
|
|
checkLoaded(image, board_default, board_default);
|
|
}
|
|
for (size_t length : {size_t(0), size_t(79), size_t(80), size_t(289)}) {
|
|
auto image = stock(1).stock1141();
|
|
image.bytes.resize(length);
|
|
for (uint8_t board_default : {0, 1}) {
|
|
CommonCLI reader;
|
|
reader.prefs.rx_boosted_gain = board_default;
|
|
reader.prefs.freq = 123.0f;
|
|
reader.load(image);
|
|
assert(reader._com_prefs_needs_upgrade && image.reads == 0);
|
|
assert(reader.prefs.rx_boosted_gain == board_default);
|
|
assert(reader.prefs.freq == 123.0f);
|
|
}
|
|
}
|
|
} else if (scenario == "modern") {
|
|
for (uint8_t saved : {0, 1}) {
|
|
auto image = stock(saved).modern();
|
|
assert(image.size() == 291 && image.bytes[79] == 0);
|
|
assert(image.bytes[290] == saved);
|
|
for (size_t length : {size_t(291), size_t(293), size_t(295), size_t(877)}) {
|
|
image.bytes.resize(length, 0);
|
|
image.bytes[79] = 1 - saved; // The appended field is authoritative.
|
|
for (uint8_t board_default : {0, 1})
|
|
checkLoaded(image, board_default, saved);
|
|
}
|
|
}
|
|
} else if (scenario == "legacy-gaps") {
|
|
for (size_t gap : {LEGACY_MQTT_GAP_3SLOT, LEGACY_MQTT_GAP_6SLOT}) {
|
|
for (size_t tail : {size_t(1), LEGACY_OBS_TAIL_MAX}) {
|
|
for (uint8_t saved : {0, 1}) {
|
|
auto image = stock(1 - saved).stock1141();
|
|
image.bytes.resize(290 + gap + tail, 0);
|
|
image.bytes[290 + gap] = saved;
|
|
for (uint8_t board_default : {0, 1})
|
|
checkLoaded(image, board_default, saved);
|
|
}
|
|
}
|
|
}
|
|
// Unknown extended layouts must not be mistaken for compact stock gain.
|
|
for (size_t extra : {LEGACY_MQTT_GAP_3SLOT + LEGACY_OBS_TAIL_MAX + 1,
|
|
LEGACY_MQTT_GAP_6SLOT,
|
|
LEGACY_MQTT_GAP_6SLOT + LEGACY_OBS_TAIL_MAX + 1}) {
|
|
auto image = stock(1).stock1141();
|
|
image.bytes.resize(290 + extra, 0);
|
|
for (uint8_t board_default : {0, 1})
|
|
checkLoaded(image, board_default, board_default);
|
|
}
|
|
// At the existing normal/gap boundary, byte290 still wins over byte79.
|
|
auto normal = stock(1).stock1141();
|
|
normal.bytes.resize(290 + LEGACY_MQTT_GAP_3SLOT, 0);
|
|
checkLoaded(normal, 1, 0);
|
|
} else if (scenario == "roundtrip") {
|
|
auto original = stock(1).stock1141();
|
|
const auto unchanged = original.bytes;
|
|
CommonCLI reader;
|
|
reader.prefs.rx_boosted_gain = 0;
|
|
reader.load(original);
|
|
auto rewritten = reader.modern();
|
|
assert(original.bytes == unchanged); // Loading does not mutate the file.
|
|
assert(rewritten.bytes[79] == 0 && rewritten.bytes[290] == 1);
|
|
checkLoaded(rewritten, 0, 1);
|
|
} else {
|
|
assert(false);
|
|
}
|
|
}
|
|
'''
|
|
|
|
|
|
def harness_source(source):
|
|
"""Extract actual prefix, branch selection, first tail field, and writer."""
|
|
load = extract_braced(source, "void CommonCLI::loadPrefsInt(")
|
|
core = load[load.index(" if (file.size() < 290)"):
|
|
load.index(' mesh::hilStartupTrace("prefs_image_core_ready");')]
|
|
gain_read = "file.read((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain));"
|
|
tail_start = load.index(" size_t extra = file.available();")
|
|
gap_read_end = load.index(gain_read, tail_start) + len(gain_read)
|
|
gap = load[tail_start:gap_read_end]
|
|
normal = extract_braced(load[gap_read_end:],
|
|
"if (file.available() >= (int)sizeof(_prefs->rx_boosted_gain))")
|
|
tail = gap + "\n }\n } else {\n" + normal + "\n }\n"
|
|
tail = tail.replace("bool has_flood_retry_prefs = false;", "[[maybe_unused]] bool has_flood_retry_prefs = false;")
|
|
constraint = re.search(r"^\s*_prefs->rx_boosted_gain = constrain[^\n]+", load, re.M).group()
|
|
save = extract_braced(source, "void CommonCLI::savePrefs(FILESYSTEM*")
|
|
writer_start = save.index(" uint8_t pad[8];")
|
|
gain_write = "file.write((uint8_t *)&_prefs->rx_boosted_gain, sizeof(_prefs->rx_boosted_gain));"
|
|
writer = save[writer_start:save.index(gain_write, writer_start) + len(gain_write)]
|
|
names = set(re.findall(r"_prefs->(\w+)", core + tail + writer))
|
|
header = (ROOT / "src/helpers/CommonCLI.h").read_text()
|
|
declarations = re.findall(r"^\s*(?:float|double|char|u?int(?:8|16|32)_t)\s+[^;]+;",
|
|
header.split("class NodePrefs :", 1)[1].split("private:", 1)[0], re.M)
|
|
fields = "\n".join(d for d in declarations if any(re.search(r"\b" + n + r"\b", d) for n in names))
|
|
fields = re.sub(r"\s*=\s*[^,;]+", "", fields)
|
|
constants = "\n".join(re.findall(r"^static const size_t LEGACY_(?:MQTT_GAP_\w+|OBS_TAIL_MAX)[^\n]+", source, re.M))
|
|
provenance = json.loads((FIXTURES / "provenance.json").read_text())
|
|
values = {"FIELDS": fields, "CORE_READER": core, "GAIN_TAIL": tail,
|
|
"GAIN_CONSTRAINT": constraint, "MODERN_WRITER": writer, "CONSTANTS": constants}
|
|
for version, marker in (("v1.14.1", "STOCK_1141"), ("v1.14.0", "STOCK_1140")):
|
|
filename = version + "-writer.inc"
|
|
data = (FIXTURES / filename).read_bytes()
|
|
if hashlib.sha256(data).hexdigest() != provenance[filename]["excerpt_sha256"]:
|
|
raise ValueError("historical source excerpt changed: " + filename)
|
|
values[marker] = data.decode()
|
|
result = HARNESS
|
|
for marker, value in values.items():
|
|
result = result.replace("@" + marker + "@", value)
|
|
return result
|
|
|
|
|
|
class CommonPrefsLegacyRxGainTest(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.temp = tempfile.TemporaryDirectory(prefix="common-prefs-legacy-gain-")
|
|
cls.addClassCleanup(cls.temp.cleanup)
|
|
cls.work = Path(cls.temp.name)
|
|
cls.source = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
|
cls.executable = cls.compile(harness_source(cls.source), "current")
|
|
|
|
@classmethod
|
|
def compile(cls, code, name):
|
|
cpp = cls.work / (name + ".cpp")
|
|
executable = cls.work / name
|
|
cpp.write_text(code)
|
|
flags = [] 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", "-Wall", "-Wextra",
|
|
"-Werror", *flags, str(cpp), "-o", str(executable)],
|
|
capture_output=True, text=True, timeout=60)
|
|
if built.returncode:
|
|
raise RuntimeError(built.stderr)
|
|
return executable
|
|
|
|
def scenario(self, name):
|
|
result = subprocess.run([str(self.executable), name], capture_output=True, text=True, timeout=10)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
|
|
def test_genuine_stock_enabled_survives_new_disabled_default(self):
|
|
self.scenario("stock-on")
|
|
|
|
def test_stock_off_and_older_padding_keep_ambiguous_zero_default(self):
|
|
self.scenario("stock-off-padding")
|
|
|
|
def test_invalid_byte_and_truncated_core_preserve_defaults(self):
|
|
self.scenario("malformed-truncated")
|
|
|
|
def test_modern_explicit_gain_wins_including_off(self):
|
|
self.scenario("modern")
|
|
|
|
def test_known_gap_gain_and_unknown_layout_defaults_remain_authoritative(self):
|
|
self.scenario("legacy-gaps")
|
|
|
|
def test_next_normal_save_keeps_recovered_gain_without_moving_fields(self):
|
|
self.scenario("roundtrip")
|
|
|
|
def test_reverting_compact_recovery_breaks_genuine_stock_on(self):
|
|
branch = extract_braced(self.source,
|
|
"if (file.size() == 290 && legacy_rx_boosted_gain == 1)")
|
|
reverted = self.source.replace(branch, "(void)legacy_rx_boosted_gain;", 1)
|
|
executable = self.compile(harness_source(reverted), "reverted")
|
|
result = subprocess.run([str(executable), "stock-on"], capture_output=True, text=True, timeout=10)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("reader.prefs.rx_boosted_gain == expected", result.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|