Files
pyxis/tests/native/test_app_launcher_nomadnet.py
T

295 lines
14 KiB
Python

import shutil
import subprocess
import hashlib
import re
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent.parent
SOURCE = HERE / "test_app_launcher_nomadnet.cpp"
INCLUDE = ROOT / "lib" / "tdeck_ui" / "UI" / "LXMF"
FIXTURES = {
HERE / "fixtures" / "nomadnet_readme.mu":
"e47de4e19e7216e48a8d441abbca1650b77ab6a2b47bedbd2144301f44172728",
HERE / "fixtures" / "columba_releases_live.mu":
"1ea4eea2b06d6988c53c19df51d38f78abd27125b4613f7a3fa5715f7461b10a",
}
def _cxx():
for name in ("clang++", "g++"):
if shutil.which(name):
return name
pytest.skip("no C++ compiler found")
def test_app_launcher_nomadnet_native(tmp_path):
# Exact upstream NomadNet 1.2.8 README.mu plus Micron generated by
# the live Columba Releases service; hashes prevent fixture drift. The
# Columba fixture is LF-normalized from raw artifact SHA-256:
# 6b93c458d31e3b74c33a0cd0333582a69a3ffd0b0033c2a228b0a85da779e37f.
for fixture, expected_hash in FIXTURES.items():
assert hashlib.sha256(fixture.read_bytes()).hexdigest() == expected_hash
binary = tmp_path / "test_app_launcher_nomadnet"
command = [
_cxx(), "-std=c++17", "-Wall", "-Wextra", "-Werror",
f"-I{INCLUDE}", str(SOURCE),
str(INCLUDE / "NomadNetDocument.cpp"),
str(INCLUDE / "NomadNetGlyphs.cpp"),
str(INCLUDE / "NomadNetLibrary.cpp"),
str(INCLUDE / "NomadNetUrl.cpp"),
"-o", str(binary),
]
compiled = subprocess.run(command, capture_output=True, text=True)
assert compiled.returncode == 0, compiled.stdout + compiled.stderr
for fixture in FIXTURES:
result = subprocess.run([str(binary), str(fixture)], capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stdout + result.stderr
assert result.stdout.strip().endswith("passed, 0 failed")
def test_ui_wiring_contract():
library_json = (ROOT / "lib" / "tdeck_ui" / "library.json").read_text()
manager_h = (INCLUDE / "UIManager.h").read_text()
manager_cpp = (INCLUDE / "UIManager.cpp").read_text()
launcher_cpp = (INCLUDE / "HomeScreen.cpp").read_text()
network_cpp = (INCLUDE / "NetworkScreen.cpp").read_text()
browser_cpp = (INCLUDE / "NomadNetScreen.cpp").read_text()
glyphs_h = (INCLUDE / "NomadNetGlyphs.h").read_text()
for tile in ("Messages", "NomadNet", "Network", "Settings"):
assert tile in launcher_cpp
for child in ("Status", "Radio Activity", "Propagation Nodes"):
assert child in network_cpp
assert "Announces" not in network_cpp
assert "set_peers_callback" in manager_cpp
assert "_nomad_actions.publish" in manager_cpp
assert "nomad_update_user_actions();" in manager_cpp
assert "begin_navigation" in browser_cpp
assert "clear_document" in browser_cpp
assert "NavigationStack" in manager_h
assert "void UIManager::navigate(" in manager_cpp
assert "void UIManager::back()" in manager_cpp
assert "void UIManager::home()" in manager_cpp
assert "SCREEN_" not in manager_h
assert "nomadnetwork\", \"node" in manager_cpp
assert "Identity::recall" in manager_cpp
assert "Transport::request_path" in manager_cpp
assert "no_form_request_data" in manager_cpp
request_start = manager_cpp.index("void UIManager::nomad_send_request()")
request = manager_cpp[request_start:manager_cpp.index("void UIManager::nomad_update()", request_start)]
assert "30.0, NomadNet::AsyncMailbox::MAX_WIRE_BYTES" in request
failed = manager_cpp[manager_cpp.index("void UIManager::on_nomad_failed"):
manager_cpp.index("void UIManager::on_nomad_progress")]
assert "receipt.response_size()" in failed
assert "NomadNet::AsyncMailbox::MAX_WIRE_BYTES + 1" in failed
assert "publish_progress" in failed
assert "response_transfer_size()" in manager_cpp
assert "DEPENDENCY HARDENING GAP" not in manager_cpp
assert "lv_obj_set_scroll_dir" in browser_cpp
assert "lv_textarea_set_max_length" in browser_cpp
assert "set_link_callback" in manager_cpp
assert "_link_targets" in browser_cpp
assert "lv_obj_scroll_to_y(_content,0,LV_ANIM_OFF);" in browser_cpp
assert "lv_group_focus_obj(_focusables.front())" not in browser_cpp
assert "LV_SYMBOL_HOME" in browser_cpp
assert "lv_font_" in browser_cpp
assert "nomadnet_font_12" in browser_cpp
assert "nomadnet_font_16" in browser_cpp
assert "display_text" in browser_cpp
assert "U+00A0-U+017F" in glyphs_h
assert "U+2000-U+206F" in glyphs_h
assert "U+2190-U+21FF" in glyphs_h
assert "+<UI/Fonts/*.c>" in library_json
for detail in ("Inbox & calls", "Browse Micron", "Links & radio", "Device options"):
assert detail in launcher_cpp
for detail in ("Interfaces & storage", "Signal history", "Delivery relays"):
assert detail in network_cpp
assert "show_browser(false)" in browser_cpp
assert "_address_summary" in browser_cpp
assert "for(auto* button:{_back_button,_home_button})" in network_cpp
for section in ("Heard Nodes", "Saved Nodes", "Saved Pages", "Recent Pages", "Enter Address"):
assert section in browser_cpp
assert "handle_library_back" in browser_cpp
assert "set_library" in browser_cpp
assert "set_save_callback" in manager_cpp
def test_directory_rebuild_has_one_final_focus_owner():
"""Returning from Heard Nodes must not focus every surviving/start row."""
screen = (INCLUDE / "NomadNetScreen.cpp").read_text()
render_start = screen.index("void NomadNetScreen::render_directory(View view)")
detach_start = screen.index("void NomadNetScreen::detach_focusables(", render_start)
rebuild_start = screen.index("void NomadNetScreen::rebuild_focus()", detach_start)
render = screen[render_start:detach_start]
detach = screen[detach_start:rebuild_start]
rebuild_end = screen.index("void NomadNetScreen::apply_browser_layout", rebuild_start)
rebuild = screen[rebuild_start:rebuild_end]
# Creating replacement buttons must not auto-enrol/focus the first object
# through LVGL's default-group constructor hook.
assert "detach_focusables(group);" in render
assert "lv_group_set_default(nullptr);" in render
assert "lv_group_set_default(previous_default_group);" in render
assert render.index("detach_focusables(group);") < render.index("lv_group_set_default(nullptr);")
assert render.index("lv_group_set_default(nullptr);") < render.index("auto add_row=")
assert render.index("lv_group_set_default(previous_default_group);") > render.index("if(_directory_focusables.empty())")
# Detach every non-owner first and the current owner last. LVGL 8.4
# otherwise refocuses each remaining member synchronously during removal.
assert "auto* focused=lv_group_get_focused(group);" in detach
assert "if(object&&object!=focused)lv_group_remove_obj(object);" in detach
assert "if(focused&&owned_focusable(focused))lv_group_remove_obj(focused);" in detach
assert detach.index("if(object&&object!=focused)lv_group_remove_obj(object);") < detach.index(
"if(focused&&owned_focusable(focused))lv_group_remove_obj(focused);"
)
# Repopulate while frozen, then emit exactly one intentional final focus.
assert "detach_focusables(group);" in rebuild
assert "lv_group_focus_freeze(group,true);" in rebuild
assert "lv_group_focus_freeze(group,false);" in rebuild
assert rebuild.index("lv_group_focus_freeze(group,true);") < rebuild.index("lv_group_add_obj")
assert rebuild.index("lv_group_focus_freeze(group,false);") < rebuild.rindex("lv_group_focus_obj")
def test_launcher_transition_has_one_final_focus_owner():
"""Leaving and restoring Home must not transiently focus later tiles."""
home = (INCLUDE / "HomeScreen.cpp").read_text()
show_start = home.index("void HomeScreen::show()")
hide_start = home.index("void HomeScreen::hide()", show_start)
show = home[show_start:hide_start]
hide_end = home.index("void HomeScreen::clicked", hide_start)
hide = home[hide_start:hide_end]
# Adding the first tile to an empty LVGL group auto-focuses it. Freeze the
# complete ordered rebuild and make Messages the sole explicit owner.
assert "lv_group_focus_freeze(group, true);" in show
assert "lv_group_focus_freeze(group, false);" in show
assert show.index("lv_group_focus_freeze(group, true);") < show.index("lv_group_add_obj")
assert show.index("lv_group_focus_freeze(group, false);") < show.index("lv_group_focus_obj(_buttons[0])")
# Hide must detach every non-owner before the current owner; removing an
# owner early makes LVGL focus each surviving launcher tile synchronously.
assert "lv_obj_t* focused = lv_group_get_focused(group);" in hide
non_owner_remove = re.search(
r"if \(button == focused\)\s*\{.*?\}\s*else\s*\{\s*lv_group_remove_obj\(button\);",
hide,
re.S,
)
assert non_owner_remove
assert "if (focused_is_launcher) lv_group_remove_obj(focused);" in hide
assert non_owner_remove.start() < hide.index(
"if (focused_is_launcher) lv_group_remove_obj(focused);"
)
def test_nomadnet_directory_uses_one_coherent_focus_style():
"""The focused row must not gain the default blue keypad outline later."""
screen = (INCLUDE / "NomadNetScreen.cpp").read_text()
add_row_start = screen.index("auto add_row=")
add_row_end = screen.index("if(view==View::START)", add_row_start)
add_row = screen[add_row_start:add_row_end]
assert "Theme::primaryPressed(),LV_STATE_FOCUSED" in add_row
assert "lv_obj_set_style_outline_width(button,0,LV_STATE_FOCUS_KEY);" in add_row
assert add_row.index("Theme::primaryPressed(),LV_STATE_FOCUSED") < add_row.index(
"lv_obj_set_style_outline_width(button,0,LV_STATE_FOCUS_KEY);"
)
def test_network_transition_has_one_final_focus_owner():
"""Network hide/show must not transiently leave the last row focused."""
screen = (INCLUDE / "NetworkScreen.cpp").read_text()
show_start = screen.index("void NetworkScreen::show()")
hide_start = screen.index("void NetworkScreen::hide()", show_start)
clicked_start = screen.index("void NetworkScreen::clicked", hide_start)
show = screen[show_start:hide_start]
hide = screen[hide_start:clicked_start]
assert "lv_group_focus_freeze(g, true);" in show
assert "lv_group_focus_freeze(g, false);" in show
assert show.index("lv_group_focus_freeze(g, true);") < show.index("lv_group_add_obj")
assert show.index("lv_group_focus_freeze(g, false);") < show.index(
"lv_group_focus_obj(_buttons[0]);"
)
assert show.count("lv_group_focus_obj") == 1
assert "lv_obj_t* focused = lv_group_get_focused(g);" in hide
assert "if (focused_is_network) lv_group_remove_obj(focused);" in hide
assert hide.index("if (object == focused)") < hide.index(
"if (focused_is_network) lv_group_remove_obj(focused);"
)
def test_bounded_nomadnet_fonts_match_display_allowlist():
expected = set(range(0x20, 0x7F)) | set(range(0xA0, 0x180))
expected |= set(range(0x2190, 0x219A))
expected |= {
0x2007, 0x2008, 0x2009, 0x200A, 0x200B,
0x2010, 0x2012, 0x2013, 0x2014, 0x2015,
0x2018, 0x2019, 0x201A, 0x201C, 0x201D, 0x201E,
0x2020, 0x2021, 0x2022, 0x2026, 0x2030, 0x2032,
0x2033, 0x2039, 0x203A, 0x2044, 0x2052,
0x20A1, 0x20A3, 0x20A4, 0x20A6, 0x20A7, 0x20A9,
0x20AB, 0x20AC, 0x20AD, 0x20AE, 0x20B1, 0x20B2,
0x20B4, 0x20B5, 0x20B8, 0x20B9, 0x20BA, 0x20BC, 0x20BD,
0x2500, 0x2550, 0x2551, 0x2554, 0x2557,
0x255A, 0x255D, 0x2588, 0x2594, 0x25A0,
}
for size in (12, 16):
source = (ROOT / "lib" / "tdeck_ui" / "UI" / "Fonts" /
f"nomadnet_font_{size}.c").read_text()
encoded = {int(value, 16) for value in re.findall(r"/\* U\+([0-9A-F]+)", source)}
assert encoded == expected
assert f".get_glyph_dsc = nomadnet_font_{size}_get_glyph_dsc" in source
assert f".get_glyph_bitmap = nomadnet_font_{size}_get_glyph_bitmap" in source
assert "nomadnet_font_has_codepoint(letter)" in source
def test_generated_font_compression_is_enabled_in_lvgl():
config = (ROOT / "lib" / "lv_conf.h").read_text()
for size in (12, 16):
source = (ROOT / "lib" / "tdeck_ui" / "UI" / "Fonts" /
f"nomadnet_font_{size}.c").read_text()
assert ".bitmap_format = 1" in source
assert "#if !LV_USE_FONT_COMPRESSED" in source
assert "#error \"NomadNet fonts require LV_USE_FONT_COMPRESSED=1\"" in source
assert "#define LV_USE_FONT_COMPRESSED 1" in config
def test_directory_icons_use_lvgl_symbol_font_separately_from_remote_text():
screen = (INCLUDE / "NomadNetScreen.cpp").read_text()
assert "add_row(LV_SYMBOL" not in screen
assert "const char* symbol=nullptr" in screen
assert "lv_label_set_text(icon,symbol)" in screen
assert "lv_obj_set_style_text_font(icon,&lv_font_montserrat_12,0)" in screen
for symbol in ("LV_SYMBOL_DIRECTORY", "LV_SYMBOL_SAVE", "LV_SYMBOL_LIST", "LV_SYMBOL_EDIT"):
assert f",{symbol});" in screen
def test_nomadnet_latency_and_path_lifecycle_contracts():
manager_cpp = (INCLUDE / "UIManager.cpp").read_text()
main_cpp = (ROOT / "src" / "main.cpp").read_text()
screen_h = (INCLUDE / "NomadNetScreen.h").read_text()
# User actions must be serviced before the known 5-15 second persistence
# boundary, otherwise Back/Open remains frozen behind flash erase/GC.
assert main_cpp.index("ui_manager->update();") < main_cpp.index("reticulum->should_persist_data();")
refresh = manager_cpp[manager_cpp.index("void UIManager::nomad_refresh_nodes()"):
manager_cpp.index("void UIManager::nomad_update_library()")]
assert refresh.index("Transport::has_path(destination_hash)") < refresh.index("Identity::recall(destination_hash)")
# Browser rendering must not trigger the expensive path-table/library poll.
assert "directory_visible() const" in screen_h
assert "std::atomic<bool> _directory_visible" in screen_h
update_library = manager_cpp[manager_cpp.index("void UIManager::nomad_update_library()"):
manager_cpp.index("void UIManager::nomad_update_user_actions()")]
assert "directory_visible()" in update_library
back = manager_cpp[manager_cpp.index("void UIManager::back()"):
manager_cpp.index("void UIManager::home()")]
assert back.index("handle_library_back()") < back.index("nomad_stop_transport();")