mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-08-28 21:38:17 +00:00
295 lines
14 KiB
Python
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();")
|