Files
MeshCore/test/test_rgb_led_rollover.py
T

135 lines
7.2 KiB
Python

"""Exercise the complete original-UI RGB status handler and its actual writers.
Clock, ADC, power and GPIO are host fakes. Separate processes preserve real
function-static initialization. This is not physical LED or power-use proof.
"""
from pathlib import Path
import os
import re
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / 'test/fixtures/rgb_led_rollover.cpp'
CASES = (
'boot-zero', 'boot-upper', 'boot-wrap', 'boot-zero-end', 'wheel-colors', 'message',
'message-wrap', 'message-retrigger', 'message-first', 'message-decrease',
'normal', 'unread', 'connected', 'low-battery', 'threshold',
'heartbeat-wrap-on', 'heartbeat-wrap-off', 'heartbeat-zero-stamp',
'heartbeat-long', 'heartbeat-msg-phase', 'battery-cadence',
'poll-wrap-before', 'poll-wrap-after', 'poll-zero-stamp', 'poll-long',
'poll-near-full', 'poll-read-delays', 'owner-fresh', 'mask-fresh',
'owner-flourish-short', 'mask-flourish-long', 'owner-on-long',
'owner-off-long', 'mask-on-long', 'mask-off-long', 'both-vetoes',
)
POWER_CASES = (
'power-cadence', 'charging', 'charged', 'charge-unread',
'charge-to-heartbeat', 'power-unpowered-charge-skip', 'poll-powered-read-delays',
)
def harness(mutation=None):
source = (ROOT / 'examples/companion_radio/ui-orig/UITask.cpp').read_text(encoding='ascii')
abstract = (ROOT / 'examples/companion_radio/AbstractUITask.h').read_text(encoding='ascii')
notification = (ROOT / 'src/helpers/CompanionNotificationPolicy.h').read_text(encoding='ascii')
output_enum = extract_braced(notification, 'enum Output : uint8_t') + ';'
constants = source[source.index('#if defined(PIN_STATUS_LED) ||'):
source.index('#ifndef USER_BTN_PRESSED')]
defaults = '\n'.join(re.findall(
r'^ (?:bool _notification_led_owned|uint8_t _notification_outputs) = .*;',
abstract, re.MULTILINE))
setters = '\n'.join(extract_braced(abstract, signature) for signature in (
'void setNotificationLedOwnership(', 'void setNotificationOutputs('))
handler = extract_braced(source, 'void UITask::userLedHandler()')
if mutation == 'no-owner-guard':
guard = 'if (_notification_led_owned || !(_notification_outputs & mesh::notify::Led)) return;'
if handler.count(guard) != 1:
raise AssertionError('the actual ownership/output guard must be present')
handler = handler.replace(guard, '', 1)
elif mutation == 'absolute-battery-deadline':
handler, count = re.subn(
r'static_cast<uint32_t>\(cur_time - (\w+)\) > 60000',
r'cur_time > static_cast<uint32_t>(\1 + 60000u)', handler)
if count != 1:
raise AssertionError('the actual elapsed battery timer must be present')
elif mutation == 'completion-time-anchor':
# The actual handler deliberately captures the clock before either read.
# Do not depend on the private name of the elapsed-time anchor.
rgb, mono = handler.split('#elif defined(PIN_STATUS_LED)', 1)
rgb, count = re.subn(r'(\b\w+\s*=\s*)cur_time;', r'\1millis();', rgb)
if count < 2:
raise AssertionError('RGB timers must retain captured-time anchors')
handler = rgb + '#elif defined(PIN_STATUS_LED)' + mono
writers = '\n'.join(extract_braced(source, signature) for signature in (
'static void statusLedWrite(', 'static void statusLedWheel('))
code = FIXTURE.read_text(encoding='ascii')
for token, value in {'CONSTANTS': constants, 'DEFAULTS': defaults,
'SETTERS': setters, 'HANDLER': handler,
'WRITERS': writers, 'OUTPUT_ENUM': output_enum}.items():
code = code.replace('@' + token + '@', value)
return code
class RgbLedRolloverTest(unittest.TestCase):
def test_actual_rgb_handler(self):
self.run_variants()
def test_ownership_regression_is_rejected(self):
self.run_variants('no-owner-guard', ('owner-fresh',), 'batt_reads == 0')
def test_captured_poll_anchor_regression_is_rejected(self):
self.run_variants('completion-time-anchor', ('poll-read-delays',), 'batt_reads == 2')
def test_absolute_poll_deadline_regression_is_rejected(self):
self.run_variants('absolute-battery-deadline', ('poll-wrap-before',),
'batt_reads == unsigned(before ? 1 : 2)')
def run_variants(self, mutation=None, cases=None, expected_failure=None):
compiler = shutil.which('g++') or shutil.which('clang++')
if compiler is None:
self.skipTest('a host C++17 compiler is required')
sanitize = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie']
with tempfile.TemporaryDirectory(prefix='rgb-led-rollover-') as directory:
work = Path(directory)
source = work / 'fixture.cpp'
source.write_text(harness(mutation), encoding='ascii')
variants = ('charge', 'no-charge') if mutation else ('charge', 'no-charge', 'disabled')
for variant in variants:
with self.subTest(variant=variant):
macros = [] if variant == 'disabled' else [
'-DPIN_STATUS_LED_R=3', '-DPIN_STATUS_LED_G=24', '-DPIN_STATUS_LED_B=28']
if variant == 'charge':
# X1 aliases its monochrome status pin to RGB green.
macros += ['-DEXT_CHRG_DETECT=35', '-DPIN_STATUS_LED=24']
binary = work / (variant + ('.exe' if os.name == 'nt' else ''))
built = subprocess.run([
compiler, '-std=c++17', '-O1', '-g', '-Wall', '-Wextra', '-Werror',
*sanitize, *macros,
'-I' + str(ROOT / 'src'), str(source), '-o', str(binary),
], capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
selected = cases if cases is not None else (
CASES + POWER_CASES if variant == 'charge' else
CASES if variant == 'no-charge' else ('no-led',))
for case in selected:
with self.subTest(case=case):
tested = subprocess.run([str(binary), case], capture_output=True,
text=True, timeout=10)
if expected_failure:
self.assertEqual(tested.returncode, 1, tested.stdout + tested.stderr)
self.assertIn(expected_failure, tested.stderr)
self.assertNotIn('AddressSanitizer:', tested.stderr)
self.assertNotIn('runtime error:', tested.stderr)
else:
self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr)
self.assertRegex(tested.stdout, r'PASS ' + case + r': \d+ checks')
if __name__ == '__main__':
unittest.main()