Files
pyxis/tests/build_scripts/test_chat_scroll_contract.py
T
Torlando ce92e8073e fix(lxmf): move conversation-open store I/O off the LVGL task
Opening a conversation crashed the same way sending did. load_conversation()
(LVGL task, under the LVGL lock held by replace_route) ran the full open
pipeline synchronously: identity recall (ustore), display-name read, the
message-index read, and the per-message metadata reads. On this device's
degraded LittleFS each op is 0.4-2s, so a cold open of a dozen-message
conversation held the LVGL mutex past the 5s deadlock guard and asserted at
LVGLLock.h:45. The send path already got the mailbox fix; the open path never
did.

Restructure with the same pattern:
- load_conversation() (LVGL task) now only navigates + resets the list and
  shows the truncated hash in the header. Same-peer re-opens return early
  with zero store I/O (rows are still built).
- prepare_conversation() (main loop, called from update()) does the store
  I/O between a short guard lock and a short commit lock, then commits the
  header name + initial bubbles + background-fill arming under a brief
  LVGL_LOCK. A generation counter discards a stale in-flight prepare when
  the conversation changes mid-I/O.
- refresh() re-arms the prepare instead of re-reading under the lock.

The 1Hz store 'not found in index' fetch is pre-existing (present on
2527c6d) and is being tracked separately as a flash-wear follow-up.

Build tdeck SUCCESS, 170/170 contract tests pass.
2026-09-05 14:39:32 +00:00

68 lines
3.4 KiB
Python

"""Source-level contracts for opening a conversation at its newest message."""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
CHAT_CPP = REPO_ROOT / "lib/tdeck_ui/UI/LXMF/ChatScreen.cpp"
CHAT_H = REPO_ROOT / "lib/tdeck_ui/UI/LXMF/ChatScreen.h"
def function_body(source: str, signature: str, next_signature: str) -> str:
start = source.index(signature)
end = source.index(next_signature, start)
return source[start:end]
def test_initial_background_fill_stays_at_newest_message():
source = CHAT_CPP.read_text()
header = CHAT_H.read_text()
prepare = function_body(source, "void ChatScreen::prepare_conversation()", "void ChatScreen::refresh()")
tick = function_body(source, "void ChatScreen::tick_background_fill()", "void ChatScreen::load_more_messages(")
on_scroll = function_body(source, "void ChatScreen::on_scroll(", "void ChatScreen::create_message_bubble(")
assert "void ChatScreen::scroll_to_bottom()" in source
helper = function_body(source, "void ChatScreen::scroll_to_bottom()", "void ChatScreen::on_scroll(")
assert "std::atomic<bool> _keep_bottom_during_background_fill" in header
assert "void scroll_to_bottom();" in header
assert helper.index("lv_obj_update_layout(_message_list)") < helper.index(
"lv_obj_scroll_to_y(_message_list, LV_COORD_MAX, LV_ANIM_OFF)"
)
# Cold-open I/O (identity recall, display name, message index, per-message
# metadata) runs on the main loop in prepare_conversation, between the
# guard lock and the commit lock — never inside a held LVGL lock, so a
# slow LittleFS open can't hold the mutex past the 5s deadlock guard.
i_io = prepare.index("Identity::recall_app_data")
i_index = prepare.index("get_messages_for_conversation")
i_meta = prepare.index("load_message_metadata")
i_commit_lock = prepare.index("commit, brief LVGL lock")
i_rows = prepare.index("create_message_bubble(item)")
i_target = prepare.index("_bg_fill_target =")
i_keep = prepare.index("_keep_bottom_during_background_fill.store(initial_fill_active)")
i_activate = prepare.index("_bg_fill_active.store(initial_fill_active)")
i_bottom = prepare.index("scroll_to_bottom()")
assert i_commit_lock < i_rows < i_target < i_keep < i_activate < i_bottom
assert i_io < i_index < i_meta < i_commit_lock
# refresh() must not do the slow store reads itself (it just re-arms the
# prepare; the main loop does the I/O off-lock).
refresh = function_body(source, "void ChatScreen::refresh()", "void ChatScreen::tick_background_fill()")
assert "load_message_metadata" not in refresh
assert "get_messages_for_conversation" not in refresh
load = tick.index("load_more_messages(")
keep_check = tick.index("if (_keep_bottom_during_background_fill.load())")
bottom_after_fill = tick.index("scroll_to_bottom()", keep_check)
assert load < keep_check < bottom_after_fill
user_fill = on_scroll.index("_bg_fill_active.store(true)")
stop_pinning = on_scroll.index("_keep_bottom_during_background_fill.store(false)")
assert stop_pinning < user_fill
def test_show_resolves_hidden_layout_before_presenting_newest_message():
source = CHAT_CPP.read_text()
show = function_body(source, "void ChatScreen::show()", "void ChatScreen::hide()")
unhide = show.index("lv_obj_clear_flag(_screen, LV_OBJ_FLAG_HIDDEN)")
bottom = show.index("scroll_to_bottom()")
assert unhide < bottom