Files
MeshCore/test/test_companion_ui_deadlines.py
T

232 lines
13 KiB
Python

#!/usr/bin/env python3
"""Run complete actual UI loops against explicit 32-bit clocks and fields.
The test adapts display/screen/radio/input dependencies, not scheduling or popup
conditions. It excludes optional button, touch, buzzer, reader and vibration
branches. Startup begin()/hardware renderers are not compiled by this boundary.
"""
from pathlib import Path
import shutil
import subprocess
import unittest
import tempfile
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = Path(__file__).resolve().parent / 'fixtures/companion_ui_deadlines.cpp'
VARIANTS = ('ui-orig', 'ui-tiny', 'ui-new')
BUILD_VARIANTS = VARIANTS + ('ui-tiny-cache',)
class CompanionUiDeadlineTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
compiler = shutil.which('g++') or shutil.which('clang++')
if compiler is None:
raise unittest.SkipTest('a host C++17 compiler is required')
cls.directory = tempfile.TemporaryDirectory(prefix='companion-ui-deadlines-')
cls.addClassCleanup(cls.directory.cleanup)
work = Path(cls.directory.name)
cls.binaries = {}
cls.startup_mutants = {}
cls.first_paint_mutant = None
for variant in BUILD_VARIANTS:
source_variant = 'ui-tiny' if variant == 'ui-tiny-cache' else variant
source = (ROOT / f'examples/companion_radio/{source_variant}/UITask.cpp').read_text(encoding='ascii')
generated = FIXTURE.read_text(encoding='ascii').replace('@LOOP@', extract_braced(source, 'void UITask::loop()'))
handler = ('void UITask::handleButtonLongPress()' if source_variant == 'ui-orig'
else 'char UITask::handleLongPress(char c)')
generated += '\n' + extract_braced(source, handler) + '\n'
generated = generated.replace('@ALERT@', '' if variant == 'ui-orig' else
extract_braced(source, 'void UITask::showAlert('))
generated = generated.replace('@SCREEN@', '' if variant == 'ui-orig' else
extract_braced(source, 'void UITask::setCurrScreen('))
cpp, binary = work / (variant + '.cpp'), work / variant
cpp.write_text(generated, encoding='ascii')
command = [
compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror', '-Wno-unused-function',
'-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie',
'-DUI_ORIG=' + str(int(variant == 'ui-orig')), '-DUI_HAS_JOYSTICK=0',
'-DUI_TINY_CACHE=' + str(int(variant == 'ui-tiny-cache')),
'-DCOMPANION_FEATURE_READER=0', '-DAUTO_OFF_MILLIS=0',
*([] if variant in ('ui-orig', 'ui-tiny-cache') else ['-DAUTO_SHUTDOWN_MILLIVOLTS=3400']),
str(cpp), '-o', str(binary)]
built = subprocess.run(command, capture_output=True, text=True, timeout=60)
if built.returncode:
raise AssertionError(built.stdout + built.stderr)
cls.binaries[variant] = binary
latch = extract_braced(source,
'if (!_startup_complete && static_cast<uint32_t>(millis() - ui_started_at) >= 8000u)')
if generated.count(latch) != 1:
raise AssertionError('startup retirement must appear once in the complete foreground loop')
mutant_cpp, mutant_binary = work / (variant + '-startup.cpp'), work / (variant + '-startup')
mutant_cpp.write_text(generated.replace(latch, '', 1), encoding='ascii')
mutant_command = [str(mutant_cpp) if arg == str(cpp)
else str(mutant_binary) if arg == str(binary) else arg for arg in command]
built = subprocess.run(mutant_command, capture_output=True, text=True, timeout=60)
if built.returncode:
raise AssertionError(built.stdout + built.stderr)
cls.startup_mutants[variant] = mutant_binary
if variant == 'ui-orig':
first_paint = extract_braced(source, 'if(_firstBoot &&')
if first_paint.count('_startup_complete || ') != 1:
raise AssertionError('first paint must recognize completed startup')
mutant_cpp, mutant_binary = work / 'orig-first-paint.cpp', work / 'orig-first-paint'
mutant_cpp.write_text(generated.replace(first_paint,
first_paint.replace('_startup_complete || ', '', 1), 1), encoding='ascii')
mutant_command = [str(mutant_cpp) if arg == str(cpp)
else str(mutant_binary) if arg == str(binary) else arg for arg in command]
built = subprocess.run(mutant_command, capture_output=True, text=True, timeout=60)
if built.returncode:
raise AssertionError(built.stdout + built.stderr)
cls.first_paint_mutant = mutant_binary
def check_scenario(self, scenario, variants=VARIANTS):
for variant in variants:
with self.subTest(variant=variant, scenario=scenario):
checked = subprocess.run([str(self.binaries[variant]), scenario],
capture_output=True, text=True, timeout=10)
print(f'\n[{variant}; {scenario}]\n' + checked.stdout + checked.stderr, flush=True)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
self.assertIn('PASS: ' + scenario, checked.stdout)
def test_healthy_refresh_schedule(self):
self.check_scenario('healthy')
def test_startup_rescue_retires_outside_gates_and_stays_closed_after_full_wrap(self):
self.check_scenario('startup-one-shot', BUILD_VARIANTS)
def test_missing_foreground_startup_retirement_is_rejected(self):
for variant, binary in self.startup_mutants.items():
with self.subTest(variant=variant):
checked = subprocess.run([str(binary), 'startup-one-shot'],
capture_output=True, text=True, timeout=10)
self.assertNotEqual(checked.returncode, 0)
self.assertIn('expired startup rescue must never reopen after full wrap', checked.stderr)
def test_orig_first_paint_can_be_deferred_past_startup_and_wrap(self):
self.check_scenario('startup-first-draw', ('ui-orig',))
checked = subprocess.run([str(self.first_paint_mutant), 'startup-first-draw'],
capture_output=True, text=True, timeout=10)
self.assertNotEqual(checked.returncode, 0)
self.assertIn('deferred first paint must remain post-startup after full wrap', checked.stderr)
def test_upper_half_refresh_schedule(self):
self.check_scenario('upper-half')
def test_actual_forced_screen_change_in_upper_half(self):
self.check_scenario('force-upper-half', VARIANTS[1:])
def test_morse_deferred_refresh_survives_wrap(self):
self.check_scenario('deferred-across-wrap')
def test_wrapped_future_deadline_is_not_force_zero(self):
self.check_scenario('wrapped-future-deadline')
def test_alert_remains_active_across_wrap(self):
self.check_scenario('alert-across-wrap', VARIANTS[1:])
def test_alert_with_zero_expiry_is_active(self):
self.check_scenario('alert-zero-deadline', VARIANTS[1:])
def test_expired_alert_never_returns_after_wrap(self):
self.check_scenario('expired-after-wrap', VARIANTS[1:])
def test_expired_alert_never_returns_after_halfwrap(self):
self.check_scenario('expired-after-halfwrap', VARIANTS[1:])
def test_protective_battery_poll_survives_wrap(self):
self.check_scenario('battery-deferred-wrap', VARIANTS[1:])
def test_healthy_and_upper_half_battery_schedule(self):
for scenario in ('battery-healthy', 'battery-upper-half'):
self.check_scenario(scenario, VARIANTS[1:])
def test_upper_half_alert_schedule(self):
self.check_scenario('alert-upper-half', VARIANTS[1:])
def test_future_battery_poll_does_not_run_early(self):
self.check_scenario('battery-wrapped-future', VARIANTS[1:])
def test_alert_expiry_is_consumed_outside_display_gate(self):
self.check_scenario('expire-with-display-off', VARIANTS[1:])
def test_alert_expiry_is_consumed_outside_morse_gate(self):
self.check_scenario('expire-during-morse', VARIANTS[1:])
def test_alert_expiry_is_consumed_outside_gesture_gate(self):
self.check_scenario('expire-during-gesture', ('ui-new',))
def test_clean_orig_preview_keeps_due_refresh_after_26_days(self):
self.check_scenario('orig-long-clean-idle', ('ui-orig',))
def test_display_off_live_loops_keep_refresh_due_after_26_days(self):
self.check_scenario('display-off-long-idle', ('ui-tiny', 'ui-new', 'ui-tiny-cache'))
def test_fresh_orig_dirty_event_preserves_one_second_throttle(self):
self.check_scenario('fresh-dirty-throttle', ('ui-orig',))
def test_tiny_cached_adc_waits_for_safe_wake_after_26_days(self):
self.check_scenario('cache-battery-long-idle', ('ui-tiny-cache',))
def test_tiny_cached_adc_future_zero_and_off_morse_gates(self):
self.check_scenario('cache-battery-wrapped-future', ('ui-tiny-cache',))
class CompanionUiDeadlineWiringContract(unittest.TestCase):
def test_begin_types_force_paths_and_ci_keep_deadlines_armed(self):
for variant in ('ui-orig', 'ui-tiny', 'ui-new'):
with self.subTest(variant=variant):
directory = ROOT / 'examples/companion_radio' / variant
source = (directory / 'UITask.cpp').read_text(encoding='ascii')
header = (directory / 'UITask.h').read_text(encoding='ascii')
begin = extract_braced(source, 'void UITask::begin(')
constructor = extract_braced(header, 'UITask(mesh::MainBoard*')
self.assertRegex(header, r'\buint32_t\s+_next_refresh\s*;')
# Construction precedes Arduino begin. It may use zero, but
# every actual begin/re-begin must re-arm from its fresh clock.
self.assertRegex(constructor, r'\b_next_refresh\s*=\s*0\s*;')
self.assertIn('ui_started_at = millis();', begin)
self.assertIn('_startup_complete = false;', begin)
self.assertIn('bool _startup_complete = false;', header)
self.assertLess(begin.index('ui_started_at = millis();'),
begin.index('_next_refresh = ui_started_at;'))
without_constructor = header.replace(constructor, '', 1)
old_force = r'\b_next_refresh\s*=\s*(?:0|100)[uUlL]*\s*;'
self.assertNotRegex(source, old_force)
self.assertNotRegex(without_constructor, old_force)
if variant == 'ui-orig':
# Re-begin does not rely on a constructor-only dirty flag.
self.assertIn('clearMsgPreview();', begin)
clear = extract_braced(source, 'void UITask::clearMsgPreview(')
self.assertIn('_need_refresh = true;', clear)
else:
self.assertRegex(header, r'\buint32_t\s+_alert_expiry\s*;')
self.assertRegex(header, r'\buint32_t\s+next_batt_chck\s*;')
self.assertIn('next_batt_chck = _next_refresh = ui_started_at;', begin)
self.assertIn('_alert[0] = 0;', begin)
self.assertIn('_alert_expiry = 0;', begin)
for initialization in ('next_batt_chck = _next_refresh = ui_started_at;',
'_alert[0] = 0;', '_alert_expiry = 0;'):
self.assertLess(begin.index(initialization),
begin.index('setCurrScreen(splash);'))
screen = extract_braced(source, 'void UITask::setCurrScreen(')
self.assertIn('_next_refresh = millis();', screen)
if variant == 'ui-new':
for signature in ('bool setTouchDebugEnabled(bool enabled)',
'void inboxModeChanged()'):
inline = extract_braced(header, signature)
self.assertIn('_next_refresh = millis();', inline)
main = (ROOT / 'examples/companion_radio/main.cpp').read_text(encoding='ascii')
self.assertIn('ui_task.begin(', extract_braced(main, 'void setup()'))
self.assertIn('ui_task.loop();', extract_braced(main, '\nvoid loop()'))
workflow = (ROOT / '.github/workflows/run-unit-tests.yml').read_text(encoding='ascii')
for module in ('test_companion_ui_deadlines', 'test_scrolling_status_bar_rollover'):
self.assertIn('python3 -B test/' + module + '.py -v', workflow)
if __name__ == '__main__':
unittest.main(verbosity=2)