mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-25 23:13:46 +00:00
252 lines
13 KiB
Python
252 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Compare the real firmware decoder and CLI output against every source verse."""
|
|
import copy
|
|
import importlib.util
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
import zlib
|
|
|
|
from test_message_navigation import PREAMBLE
|
|
from test_replay_reset_integration import extract_braced
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
FIXTURE = ROOT / "test/fixtures/companion_reader"
|
|
spec = importlib.util.spec_from_file_location("pack_reader", ROOT / "tools/bible/pack_reader.py")
|
|
packer = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(packer)
|
|
|
|
|
|
class CompanionReaderTest(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.document = json.loads(packer.SOURCE.read_text(encoding="utf-8"))
|
|
# Independent expected typography, not the packer's conversion helper.
|
|
typography = str.maketrans({"\u00a0": " ", "\u2013": "-", "\u2014": "-",
|
|
"\u2018": "'", "\u2019": "'", "\u201c": '"', "\u201d": '"'})
|
|
cls.expected_verses = {ref: text.translate(typography)
|
|
for ref, text in cls.document["verses"].items()}
|
|
|
|
def test_generated_header_is_current(self):
|
|
self.assertEqual(packer.HEADER.read_text(encoding="utf-8"), packer.render(self.document))
|
|
|
|
def test_block_sizes_roundtrip_all_verses(self):
|
|
for size in (1024, 2048, 4096):
|
|
packed, blocks = packer.pack(self.document, size)
|
|
actual = []
|
|
for offset, length, plain_length, first in blocks:
|
|
self.assertEqual(first, len(actual))
|
|
plain = zlib.decompress(packed[offset:offset + length], wbits=-15)
|
|
self.assertEqual(len(plain), plain_length)
|
|
self.assertLessEqual(len(plain), size)
|
|
self.assertEqual(plain[-1], 0)
|
|
actual.extend(part.decode("ascii") for part in plain[:-1].split(b"\0"))
|
|
self.assertEqual(actual, list(self.expected_verses.values()))
|
|
|
|
def test_ascii_typography_preserves_words_case_and_pinned_source(self):
|
|
original = copy.deepcopy(self.document)
|
|
sample = '\u201creader\u2019s\u00a0Word\u201d\u2014\u2018Test\u2019\u2013Yes!'
|
|
self.assertEqual(packer.ascii_verse(sample, 64), b'"reader\'s Word"-\'Test\'-Yes!')
|
|
self.assertEqual(packer.ascii_verse('Already ASCII: reader 3:16.', 64),
|
|
b'Already ASCII: reader 3:16.')
|
|
# Bounds apply after conversion, not to the original multibyte form.
|
|
self.assertEqual(packer.ascii_verse('\u201cHi\u201d', 4), b'"Hi"')
|
|
with self.assertRaises(ValueError):
|
|
packer.ascii_verse('\u201cHi\u201d', 3)
|
|
packer.pack(self.document)
|
|
self.assertEqual(self.document, original)
|
|
|
|
def test_ascii_data_size(self):
|
|
packed, blocks = packer.pack(self.document)
|
|
self.assertEqual(sum(block[2] for block in blocks), 96568)
|
|
self.assertEqual(len(blocks), 49)
|
|
self.assertEqual(len(packed) + 12 * len(blocks), 43891)
|
|
|
|
def test_invalid_sources_are_rejected(self):
|
|
for bad_text in ("", "leading\ncommand", "bad\x1b[2J", "bad\0text",
|
|
"bad\u202eRTL", " too much space", "a" * 2048,
|
|
"caf\u00e9", "unmapped\u2026punctuation", "bad\U0001f600text"):
|
|
document = copy.deepcopy(self.document)
|
|
document["verses"]["1:1"] = bad_text
|
|
with self.assertRaises(ValueError):
|
|
packer.pack(document)
|
|
for key in ("1:1", "21:25"):
|
|
document = copy.deepcopy(self.document)
|
|
del document["verses"][key]
|
|
with self.assertRaises(ValueError):
|
|
packer.pack(document)
|
|
|
|
def test_firmware_decoder_and_terminal_on_both_platform_profiles(self):
|
|
cc, cxx = shutil.which("gcc"), shutil.which("g++")
|
|
if not cc or not cxx:
|
|
self.skipTest("host GCC and G++ required")
|
|
with tempfile.TemporaryDirectory(prefix="meshcore-reader-") as directory:
|
|
directory = Path(directory)
|
|
for platform in ("ESP32_PLATFORM", "NRF52_PLATFORM"):
|
|
flags = [f"-D{platform}=1", "-DCOMPANION_RADIO_FULL=1", "-DENABLE_USB_INTERFACE=1"]
|
|
obj, binary = directory / "tinf.o", directory / "reader.exe"
|
|
self.run_checked([cc, "-std=c11", "-Os", *flags, "-c",
|
|
str(ROOT / "src/helpers/ota/OtaTinf.c"), "-o", str(obj)])
|
|
self.run_checked([cxx, "-std=c++17", "-Os", "-Wall", "-Wextra", "-Werror",
|
|
*flags, "-I" + str(ROOT / "src"), "-I" + str(FIXTURE),
|
|
str(FIXTURE / "test_reader_lookup.cpp"),
|
|
str(ROOT / "src/helpers/CompanionReader.cpp"), str(obj), "-o", str(binary)])
|
|
result = self.run_checked([str(binary), "--dump"])
|
|
actual = dict(line.split("\t", 1) for line in result.splitlines())
|
|
self.assertEqual(actual, self.expected_verses)
|
|
|
|
def test_other_profiles_exclude_corpus(self):
|
|
cxx = shutil.which("g++")
|
|
if not cxx:
|
|
self.skipTest("host G++ required")
|
|
for flags in ([], ["-DESP32_PLATFORM=1"], ["-DNRF52_PLATFORM=1"],
|
|
["-DESP32_PLATFORM=1", "-DCOMPANION_RADIO_FULL=1"],
|
|
["-DESP32_PLATFORM=1", "-DCOMPANION_RADIO_FULL=1",
|
|
"-DENABLE_USB_INTERFACE=1", "-DCOMPANION_FEATURE_READER=0"]):
|
|
result = self.run_checked([cxx, "-E", "-P", *flags,
|
|
str(ROOT / "src/helpers/CompanionReader.cpp")])
|
|
self.assertNotIn("readerData", result)
|
|
self.assertNotIn("handleReaderCommand", result)
|
|
|
|
def test_terminal_dispatch_is_separate_from_bounded_api_replies(self):
|
|
source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="utf-8")
|
|
start = source.index("void MyMesh::handleTerminalCommand(")
|
|
end = source.index("\nvoid MyMesh::enterCLIRescue()", start)
|
|
terminal = source[start:end]
|
|
self.assertEqual(source.count("mesh::handleReaderCommand("), 1)
|
|
self.assertLess(terminal.index("mesh::handleReaderCommand("),
|
|
terminal.index("char local_reply[160]"))
|
|
self.assertIn("get reader <chapter>:<verse> (World English Bible, offline)", terminal)
|
|
self.assertEqual(terminal.count("#if COMPANION_FEATURE_READER"), 2)
|
|
|
|
def test_reader_navigation_and_bookmark_recovery(self):
|
|
cc, cxx = shutil.which("gcc"), shutil.which("g++")
|
|
if not cc or not cxx:
|
|
self.skipTest("host GCC and G++ required")
|
|
with tempfile.TemporaryDirectory(prefix="meshcore-reader-reader-") as directory:
|
|
directory = Path(directory)
|
|
flags = ["-DNRF52_PLATFORM=1", "-DCOMPANION_RADIO_FULL=1", "-DENABLE_USB_INTERFACE=1"]
|
|
obj, binary = directory / "tinf.o", directory / "reader.exe"
|
|
self.run_checked([cc, "-Os", *flags, "-c", str(ROOT / "src/helpers/ota/OtaTinf.c"),
|
|
"-o", str(obj)])
|
|
for small_font in (0, 1):
|
|
for button_hint, touch_bar in ((0, 0), (1, 0), (1, 1)):
|
|
self.run_checked([cxx, "-std=c++11", "-Os", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter",
|
|
*flags, f"-DUI_SMALL_MESSAGE_FONT={small_font}",
|
|
f"-DUI_BUTTON_READER_HINT={button_hint}",
|
|
f"-DUI_READER_TOUCH_BAR={touch_bar}",
|
|
"-I" + str(ROOT / "src"), "-I" + str(FIXTURE),
|
|
str(FIXTURE / "test_reader_screen.cpp"), str(ROOT / "src/helpers/CompanionReader.cpp"),
|
|
str(obj), "-o", str(binary)])
|
|
self.run_checked([str(binary)])
|
|
|
|
def test_reader_button_routing_and_profile_gate(self):
|
|
ui = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text(encoding="utf-8")
|
|
long_press = ui[ui.index("char UITask::handleLongPress("):]
|
|
self.assertLess(long_press.index("isReaderActive()"), long_press.index("enterCLIRescue()"))
|
|
self.assertLess(long_press.index("isRadioPage()"), long_press.index("enterCLIRescue()"))
|
|
self.assertIn("#if COMPANION_FEATURE_READER\n#include \"ReaderScreen.h\"", ui)
|
|
self.assertLess(ui.index("#define UI_BUTTON_READER_HINT 1"),
|
|
ui.index('#include "ReaderScreen.h"'))
|
|
# Queue downloads preserve the current screen unconditionally now;
|
|
# test_companion_inbox exercises this with the reader enabled.
|
|
self.assertIn("else if (isReaderActive())", ui)
|
|
self.assertIn("c = handleLongPress(KEY_ENTER);", ui)
|
|
self.assertIn("handleDoubleClick(KEY_PREV)", ui)
|
|
|
|
def test_reader_bookmark_follows_display_power_transitions(self):
|
|
ui = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text(encoding="utf-8")
|
|
stubs = r'''
|
|
struct Board {
|
|
bool external = false, host = false;
|
|
bool isExternalPowered() const { return external; }
|
|
bool isUsbHostConnected() const { return host; }
|
|
};
|
|
struct Interfaces { bool takePairingRequest() { return false; } };
|
|
struct ReaderScreen : Screen { int saves = 0; void flush() { ++saves; } };
|
|
'''
|
|
members = r'''
|
|
Board* _board;
|
|
Interfaces* _interfaceManager;
|
|
uint32_t _pairing_screen_until = 0;
|
|
bool hasConnection() const { return false; }
|
|
bool isPairingScreenActive() const { return false; }
|
|
void showPairingPin() {}
|
|
void finishPairingScreen(bool) {}
|
|
void servicePairingState();
|
|
'''
|
|
source = PREAMBLE.replace("class UITask {", stubs + "class UITask {")
|
|
source = source.replace("int getMsgCount() const { return 0; }", members)
|
|
source += extract_braced(ui, "void UITask::servicePairingState(") + "\n"
|
|
source += extract_braced(ui, "char UITask::checkDisplayOn(") + r'''
|
|
int main() {
|
|
using namespace mesh::ui;
|
|
resetArduinoMock();
|
|
displayPowerPrefs().battery = {DisplayMode::Button, 5};
|
|
displayPowerPrefs().usb = {DisplayMode::On, 5};
|
|
Display display;
|
|
Screen home;
|
|
ReaderScreen reader;
|
|
Board board;
|
|
Interfaces interfaces;
|
|
UITask task(display);
|
|
task._board = &board; task._interfaceManager = &interfaces;
|
|
task.curr = task.home = &home; task.reader = &reader;
|
|
task.servicePairingState();
|
|
assert(!display.isOn() && reader.saves == 0);
|
|
task.curr = &reader;
|
|
assert(task.checkDisplayOn(KEY_NEXT) == 0); // Wake consumes navigation.
|
|
assert(display.isOn());
|
|
g_mock_millis = 4999; task.servicePairingState();
|
|
assert(display.isOn() && reader.saves == 0);
|
|
g_mock_millis = 5000; task.servicePairingState();
|
|
assert(!display.isOn());
|
|
const int expected_saves = COMPANION_FEATURE_READER ? 1 : 0;
|
|
assert(reader.saves == expected_saves);
|
|
task.servicePairingState();
|
|
assert(reader.saves == expected_saves); // No repeated writes while dark.
|
|
for (bool* plugged : {&board.external, &board.host}) {
|
|
*plugged = true; task.servicePairingState();
|
|
assert(display.isOn());
|
|
g_mock_millis += 10000; task.servicePairingState();
|
|
assert(display.isOn()); // USB's always-on policy applies to the reader.
|
|
int before = reader.saves;
|
|
*plugged = false; task.servicePairingState();
|
|
assert(!display.isOn() && reader.saves == before + expected_saves);
|
|
}
|
|
task.curr = &home;
|
|
task.checkDisplayOn(KEY_NEXT);
|
|
int before = reader.saves;
|
|
g_mock_millis += 5000; task.servicePairingState();
|
|
assert(!display.isOn() && reader.saves == before);
|
|
task._display = nullptr; task.servicePairingState();
|
|
}
|
|
'''
|
|
compiler = shutil.which("g++")
|
|
self.assertIsNotNone(compiler, "host G++ required")
|
|
with tempfile.TemporaryDirectory(prefix="mesh-reader-power-") as directory:
|
|
directory = Path(directory)
|
|
(directory / "power.cpp").write_text(source, encoding="utf-8")
|
|
for enabled in (0, 1):
|
|
with self.subTest(reader_enabled=enabled):
|
|
binary = directory / "power.exe"
|
|
self.run_checked([compiler, "-std=c++17", f"-DCOMPANION_FEATURE_READER={enabled}",
|
|
"-I" + str(ROOT / "src"), "-I" + str(ROOT / "test/mocks"),
|
|
str(directory / "power.cpp"),
|
|
str(ROOT / "src/helpers/ui/MomentaryButton.cpp"),
|
|
str(ROOT / "src/helpers/ui/DisplayDriver.cpp"), "-o", str(binary)])
|
|
self.run_checked([str(binary)])
|
|
|
|
def run_checked(self, command):
|
|
result = subprocess.run(command, capture_output=True, encoding="utf-8",
|
|
errors="backslashreplace", timeout=60)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
return result.stdout
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|