Files
MeshCore/test/test_micronmea_time_fix.py
T

205 lines
9.9 KiB
Python

"""Exercise the reviewed complete GPS parser and its private build transform.
The compiler uses the real MicroNMEA fixture with Arduino/Stream adapters.
No PlatformIO command, dependency download, serial port or hardware is used.
"""
from pathlib import Path
import importlib.util
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
FIXTURES = ROOT / "test/fixtures/micronmea_time_fix"
SPEC = importlib.util.spec_from_file_location("micronmea_time_fix", ROOT / "scripts/micronmea_time_fix.py")
FIX = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(FIX)
SOURCE = (FIXTURES / "src/MicroNMEA.cpp").read_text(encoding="ascii")
HEADER = (FIXTURES / "src/MicroNMEA.h").read_text(encoding="ascii")
class Node:
def __init__(self, path):
self.path = Path(path)
def srcnode(self):
return self
def get_abspath(self):
return str(self.path)
class BuildEnv:
def __init__(self, build):
self.build = Path(build)
self.registrations = []
def subst(self, value):
assert value == "$BUILD_DIR"
return str(self.build)
def File(self, path):
return Path(path)
def AddBuildMiddleware(self, callback, pattern):
self.registrations.append((callback, pattern))
class TransformTests(unittest.TestCase):
def test_original_fixtures_are_exact_ascii_reviewed_and_attributed(self):
for path in FIXTURES.rglob("*"):
if path.is_file():
path.read_text(encoding="ascii")
self.assertEqual(FIX.digest(SOURCE), FIX.SOURCE_SHA256)
self.assertEqual(FIX.digest(HEADER), FIX.HEADER_SHA256)
self.assertIn('#define MICRONMEA_VERSION "2.0.6"', HEADER)
self.assertIn("Steve Marple", (FIXTURES / "README.txt").read_text())
license_text = (FIXTURES / "LICENSE").read_text(encoding="ascii")
self.assertIn("GNU LESSER GENERAL PUBLIC LICENSE", license_text)
self.assertIn("Version 2.1, February 1999", license_text)
def test_transform_is_hash_bound_and_idempotent_with_lf_or_crlf(self):
patched = FIX.patched_source(SOURCE)
self.assertEqual(FIX.digest(patched), FIX.PATCHED_SHA256)
self.assertEqual(FIX.patched_source(SOURCE.replace("\n", "\r\n")), patched)
self.assertEqual(FIX.patched_source(patched), patched)
self.assertEqual(FIX.patched_source(patched.replace("\n", "\r\n")), patched)
self.assertIn(FIX.PATCH_NOTICE, patched)
self.assertEqual(patched.count(FIX.PATCHED_TIME), 1)
self.assertEqual(patched.count(FIX.PATCHED_DATE), 1)
def test_unknown_changed_and_half_patched_sources_fail_closed(self):
for source in (SOURCE + "\n", SOURCE.replace("return r;", "return r + 1;", 1),
SOURCE.replace(FIX.ORIGINAL_TIME, FIX.PATCHED_TIME, 1),
SOURCE.replace(FIX.ORIGINAL_DATE, FIX.PATCHED_DATE, 1)):
with self.subTest(source=FIX.digest(source)):
with self.assertRaisesRegex(RuntimeError, "changed reviewed source"):
FIX.patched_source(source)
def test_middleware_matches_only_the_expected_library_source(self):
env = BuildEnv("unused")
FIX.install(env)
self.assertEqual(env.registrations, [(FIX.replace_micronmea_source, "*MicroNMEA.cpp")])
with tempfile.TemporaryDirectory() as temporary:
env = BuildEnv(Path(temporary) / "build")
for relative in ("src/unrelated.cpp", "other/MicroNMEA.cpp"):
node = Node(Path(temporary) / relative)
self.assertIs(FIX.replace_micronmea_source(env, node), node)
self.assertFalse(env.build.exists())
def test_private_pair_leaves_downloaded_files_and_unchanged_outputs_untouched(self):
for crlf in (False, True):
with self.subTest(crlf=crlf), tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "libdeps/MicroNMEA/src/MicroNMEA.cpp"
source.parent.mkdir(parents=True)
original = SOURCE.replace("\n", "\r\n") if crlf else SOURCE
header = HEADER.replace("\n", "\r\n") if crlf else HEADER
source.write_bytes(original.encode("ascii"))
source.with_suffix(".h").write_bytes(header.encode("ascii"))
before = source.read_bytes(), source.with_suffix(".h").read_bytes()
env = BuildEnv(root / "build")
destination = FIX.replace_micronmea_source(env, Node(source))
self.assertEqual(destination, env.build / "patched-micronmea/MicroNMEA.cpp")
self.assertEqual(destination.read_text(encoding="ascii"), FIX.patched_source(SOURCE))
self.assertEqual(destination.with_suffix(".h").read_text(encoding="ascii"), HEADER)
times = destination.stat().st_mtime_ns, destination.with_suffix(".h").stat().st_mtime_ns
self.assertEqual(FIX.replace_micronmea_source(env, Node(source)), destination)
self.assertEqual(times, (destination.stat().st_mtime_ns,
destination.with_suffix(".h").stat().st_mtime_ns))
self.assertEqual(before, (source.read_bytes(), source.with_suffix(".h").read_bytes()))
def test_missing_or_changed_header_fails_before_creating_private_output(self):
for header in (None, HEADER + "\n", "// unknown\n", "\u00b0"):
with self.subTest(missing=header is None), tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "libdeps/MicroNMEA/src/MicroNMEA.cpp"
source.parent.mkdir(parents=True)
source.write_text(SOURCE, encoding="ascii")
if header is not None:
source.with_suffix(".h").write_text(header, encoding="utf-8")
env = BuildEnv(root / "build")
with self.assertRaisesRegex(RuntimeError, "reviewed header"):
FIX.replace_micronmea_source(env, Node(source))
self.assertFalse(env.build.exists())
class RuntimeTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
compiler = shutil.which("g++") or shutil.which("clang++")
if not compiler:
raise unittest.SkipTest("a host C++17 compiler is required")
temporary = tempfile.TemporaryDirectory(prefix="micronmea-time-")
cls.addClassCleanup(temporary.cleanup)
root = Path(temporary.name)
cls.binaries = {}
sanitize = [] if not sys.platform.startswith("linux") else [
"-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-omit-frame-pointer", "-fno-pie", "-no-pie"]
for kind in ("original", "patched"):
source = root / kind / "src/MicroNMEA.cpp"
source.parent.mkdir(parents=True)
source.write_text(SOURCE, encoding="ascii")
source.with_suffix(".h").write_text(HEADER, encoding="ascii")
if kind == "patched":
source = FIX.replace_micronmea_source(BuildEnv(root / "private"), Node(source))
binary = root / (kind + ".exe")
built = subprocess.run([
compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", *sanitize, "-I" + str(source.parent),
"-isystem", str(ROOT / "test/mocks"), str(FIXTURES / "runtime.cpp"),
str(source), "-o", str(binary)], capture_output=True, text=True, timeout=60)
if built.returncode:
raise AssertionError(built.stdout + built.stderr)
cls.binaries[kind] = binary
# Force a wrong header ahead of the reviewed private pair. Only the
# library implementation is compiled here; its quoted include must win.
poison = root / "poison"
poison.mkdir()
(poison / "MicroNMEA.h").write_text('#error "unpaired header"\n', encoding="ascii")
checked = subprocess.run([
compiler, "-std=c++17", "-I" + str(poison), "-I" + str(ROOT / "test/mocks"),
"-c", str(root / "private/patched-micronmea/MicroNMEA.cpp"),
"-o", str(root / "private.o")], capture_output=True, text=True, timeout=30)
if checked.returncode:
raise AssertionError(checked.stdout + checked.stderr)
def run_case(self, scenario, kind="patched", diagnostic=None):
result = subprocess.run([str(self.binaries[kind]), scenario],
capture_output=True, text=True, timeout=10)
if diagnostic:
self.assertEqual(result.returncode, 42, result.stdout + result.stderr)
self.assertIn("SEMANTIC: " + diagnostic, result.stderr)
else:
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn("AddressSanitizer:", result.stderr)
self.assertNotIn("runtime error:", result.stderr)
def test_integer_and_variable_fraction_times_preserve_real_position_and_raw_sentences(self):
self.run_case("formats")
def test_malformed_clock_fields_clear_utc_but_preserve_valid_position_and_recover(self):
self.run_case("invalid")
def test_terminal_date_without_trailing_optional_fields_is_still_valid(self):
self.run_case("terminal-date")
def test_bad_checksum_unknown_sentence_crlf_and_send_callbacks_are_unchanged(self):
self.run_case("callbacks")
self.run_case("callbacks", "original")
def test_small_buffer_and_end_of_buffer_short_date_stay_in_bounds(self):
self.run_case("bounds")
self.run_case("near-buffer-date")
def test_original_parser_fails_semantic_compatibility_and_stale_utc_oracles(self):
self.run_case("formats", "original", "RMC short time format lost validity")
self.run_case("invalid", "original", "incomplete date retained old UTC date")
if __name__ == "__main__":
unittest.main()