mirror of
https://github.com/pdxlocations/contact.git
synced 2026-10-31 16:21:57 +01:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31bff496f1 | ||
|
|
b7c3ed20f2 | ||
|
|
7f7e0598d2 | ||
|
|
82e7456248 | ||
|
|
7b21c9c886 | ||
|
|
392d236a40 | ||
|
|
546fab099c | ||
|
|
a89d4f8c0d | ||
|
|
808fee4fe0 | ||
|
|
94d0a9053e | ||
|
|
f38167db1a | ||
|
|
57caf463bc |
No files matched your search
+6
-11
@@ -38,6 +38,7 @@ from contact.utilities.input_handlers import get_list_input
|
||||
from contact.utilities.i18n import t
|
||||
from contact.ui.dialog import dialog
|
||||
from contact.utilities.interfaces import initialize_interface, reconnect_interface
|
||||
from contact.utilities.logging_utils import configure_logging
|
||||
from contact.utilities.utils import get_channels, get_nodeNum, get_node_list
|
||||
from contact.utilities.singleton import ui_state, interface_state, app_state
|
||||
|
||||
@@ -51,15 +52,7 @@ os.environ.setdefault("TERM", "xterm-256color")
|
||||
if os.environ.get("COLORTERM") == "gnome-terminal":
|
||||
os.environ["TERM"] = "xterm-256color"
|
||||
|
||||
logging.basicConfig(
|
||||
filename=config.log_file_path,
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||
# Meshtastic or another dependency may have installed a stderr handler
|
||||
# before Contact starts curses. Replace it so background warnings cannot
|
||||
# overwrite the terminal UI.
|
||||
force=True,
|
||||
)
|
||||
configure_logging(logging.INFO)
|
||||
|
||||
app_state.lock = threading.Lock()
|
||||
|
||||
@@ -121,7 +114,7 @@ def interface_is_ready(interface: object) -> bool:
|
||||
|
||||
def initialize_runtime_interface_with_retry(stdscr: curses.window, args: object):
|
||||
while True:
|
||||
interface = initialize_runtime_interface(args)
|
||||
interface = initialize_runtime_interface(args, stdscr)
|
||||
if getattr(args, "demo_screenshot", False) or interface_is_ready(interface):
|
||||
return interface
|
||||
|
||||
@@ -162,10 +155,12 @@ def initialize_globals(seed_demo: bool = False) -> None:
|
||||
load_messages_from_db()
|
||||
|
||||
|
||||
def initialize_runtime_interface(args: object):
|
||||
def initialize_runtime_interface(args: object, stdscr=None):
|
||||
if getattr(args, "demo_screenshot", False):
|
||||
configure_demo_database()
|
||||
return build_demo_interface()
|
||||
if stdscr is not None:
|
||||
return initialize_interface(args, status_callback=lambda status: draw_splash(stdscr, status))
|
||||
return initialize_interface(args)
|
||||
|
||||
|
||||
|
||||
+3
-7
@@ -14,6 +14,7 @@ from contact.utilities.arg_parser import setup_parser
|
||||
from contact.utilities.i18n import t
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
from contact.utilities.interfaces import initialize_interface, reconnect_interface
|
||||
from contact.utilities.logging_utils import configure_logging
|
||||
|
||||
|
||||
def close_interface(interface: object) -> None:
|
||||
@@ -36,7 +37,7 @@ def main(stdscr: curses.window) -> None:
|
||||
|
||||
parser = setup_parser()
|
||||
args = parser.parse_args()
|
||||
interface = initialize_interface(args)
|
||||
interface = initialize_interface(args, status_callback=lambda status: draw_splash(stdscr, status))
|
||||
|
||||
if interface.localNode.localConfig.lora.region == 0:
|
||||
confirmation = get_list_input(
|
||||
@@ -81,12 +82,7 @@ def ensure_min_rows(stdscr: curses.window, min_rows: int = 11) -> None:
|
||||
stdscr.refresh()
|
||||
|
||||
|
||||
logging.basicConfig( # Run `tail -f client.log` in another terminal to view live
|
||||
filename=config.log_file_path,
|
||||
level=logging.WARNING, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||
force=True,
|
||||
)
|
||||
configure_logging(logging.WARNING)
|
||||
|
||||
if __name__ == "__main__":
|
||||
log_file = config.log_file_path
|
||||
|
||||
+50
-17
@@ -246,13 +246,14 @@ def compute_widths(total_w: int, focus: int):
|
||||
return MIN_COL, MIN_COL, total_w - 2 * MIN_COL
|
||||
|
||||
|
||||
def paint_frame(win, selected: bool) -> None:
|
||||
def paint_frame(win, selected: bool, refresh: bool = True) -> None:
|
||||
if win is not entry_win and ui_state.input_focused:
|
||||
selected = False
|
||||
win.attrset(get_color("window_frame_selected") if selected else get_color("window_frame"))
|
||||
win.box()
|
||||
win.attrset(get_color("window_frame"))
|
||||
win.refresh()
|
||||
if refresh:
|
||||
win.refresh()
|
||||
|
||||
|
||||
def get_channel_row_color(index: int) -> int:
|
||||
@@ -275,7 +276,7 @@ def get_node_row_color(index: int, highlight: bool = False) -> int:
|
||||
return get_color(color, reverse=reverse)
|
||||
|
||||
|
||||
def refresh_node_selection(old_index: int = -1, highlight: bool = False) -> None:
|
||||
def refresh_node_selection(old_index: int = -1, highlight: bool = False, refresh: bool = True) -> None:
|
||||
if nodes_pad is None or not ui_state.node_list:
|
||||
return
|
||||
|
||||
@@ -294,8 +295,9 @@ def refresh_node_selection(old_index: int = -1, highlight: bool = False) -> None
|
||||
pass
|
||||
|
||||
ui_state.start_index[2] = max(0, ui_state.selected_node - (nodes_win.getmaxyx()[0] - 3))
|
||||
refresh_pad(2)
|
||||
draw_window_arrows(2)
|
||||
if refresh:
|
||||
refresh_pad(2)
|
||||
draw_window_arrows(2)
|
||||
|
||||
|
||||
def refresh_main_window(window_id: int, selected: bool) -> None:
|
||||
@@ -306,7 +308,7 @@ def refresh_main_window(window_id: int, selected: bool) -> None:
|
||||
channel_pad.chgat(ui_state.selected_channel, 1, width, get_channel_row_color(ui_state.selected_channel))
|
||||
refresh_pad(0)
|
||||
elif window_id == 1:
|
||||
paint_frame(messages_win, selected=selected)
|
||||
paint_frame(messages_win, selected=selected, refresh=False)
|
||||
refresh_pad(1)
|
||||
elif window_id == 2:
|
||||
paint_frame(nodes_win, selected=selected)
|
||||
@@ -700,7 +702,7 @@ def draw_message_input(draft: str) -> None:
|
||||
used += text_width(content[start])
|
||||
visible = slice_to_width(content[start:], available)
|
||||
draw_text_field(entry_win, prompt + visible, get_color("input"))
|
||||
paint_frame(entry_win, selected=ui_state.input_focused)
|
||||
paint_frame(entry_win, selected=ui_state.input_focused, refresh=False)
|
||||
try:
|
||||
hint = " Ctrl+K Help "
|
||||
if width >= len(hint) + 4:
|
||||
@@ -935,6 +937,28 @@ def handle_enter(input_text: str) -> str:
|
||||
return input_text
|
||||
|
||||
|
||||
def stage_node_details_background(stdscr, previous_window):
|
||||
"""Restore the panes behind a moved dialog without displaying an intermediate frame."""
|
||||
current_window = ui_state.current_window
|
||||
try:
|
||||
ui_state.current_window = previous_window
|
||||
stdscr.touchwin()
|
||||
stdscr.noutrefresh()
|
||||
windows = (channel_win, messages_win, nodes_win)
|
||||
for index, win in enumerate(windows):
|
||||
if ui_state.single_pane_mode and index != previous_window:
|
||||
continue
|
||||
win.touchwin()
|
||||
refresh_pad(index, commit=False)
|
||||
if ui_state.display_log and (not ui_state.single_pane_mode or previous_window == 1):
|
||||
packetlog_win.touchwin()
|
||||
packetlog_win.noutrefresh()
|
||||
entry_win.touchwin()
|
||||
entry_win.noutrefresh()
|
||||
finally:
|
||||
ui_state.current_window = current_window
|
||||
|
||||
|
||||
def handle_f5_key(stdscr: curses.window) -> None:
|
||||
if not ui_state.node_list:
|
||||
return
|
||||
@@ -1032,6 +1056,7 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
ui_state.current_window = 4
|
||||
scroll_offset = 0
|
||||
dialog_win = None
|
||||
previous_layout = None
|
||||
|
||||
curses.curs_set(0)
|
||||
refresh_node_selection(highlight=True)
|
||||
@@ -1051,11 +1076,15 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
max_scroll = max(0, len(message_lines) - viewport_h)
|
||||
scroll_offset = max(0, min(scroll_offset, max_scroll))
|
||||
|
||||
layout = (dialog_height, dialog_width, y, x, ui_state.selected_node, height, width)
|
||||
if previous_layout is not None and layout != previous_layout:
|
||||
stage_node_details_background(stdscr, previous_window)
|
||||
previous_layout = layout
|
||||
|
||||
if dialog_win is None:
|
||||
dialog_win = curses.newwin(dialog_height, dialog_width, y, x)
|
||||
else:
|
||||
dialog_win.erase()
|
||||
dialog_win.refresh()
|
||||
dialog_win.resize(dialog_height, dialog_width)
|
||||
dialog_win.mvwin(y, x)
|
||||
|
||||
@@ -1100,7 +1129,7 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
dialog_win.refresh()
|
||||
dialog_win.noutrefresh()
|
||||
msg_win.noutrefresh()
|
||||
curses.doupdate()
|
||||
|
||||
@@ -1120,12 +1149,12 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
old_selected_node = ui_state.selected_node
|
||||
ui_state.selected_node = (ui_state.selected_node - 1) % len(ui_state.node_list)
|
||||
scroll_offset = 0
|
||||
refresh_node_selection(old_selected_node, highlight=True)
|
||||
refresh_node_selection(old_selected_node, highlight=True, refresh=False)
|
||||
elif char == curses.KEY_DOWN:
|
||||
old_selected_node = ui_state.selected_node
|
||||
ui_state.selected_node = (ui_state.selected_node + 1) % len(ui_state.node_list)
|
||||
scroll_offset = 0
|
||||
refresh_node_selection(old_selected_node, highlight=True)
|
||||
refresh_node_selection(old_selected_node, highlight=True, refresh=False)
|
||||
elif char == curses.KEY_PPAGE:
|
||||
scroll_offset = max(0, scroll_offset - viewport_h)
|
||||
elif char == curses.KEY_NPAGE:
|
||||
@@ -1642,7 +1671,7 @@ def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: boo
|
||||
messages_pad.addstr(row, 1, line, color)
|
||||
ui_state.message_line_ranges[channel] = message_ranges
|
||||
|
||||
paint_frame(messages_win, selected=(ui_state.current_window == 1))
|
||||
paint_frame(messages_win, selected=(ui_state.current_window == 1), refresh=False)
|
||||
|
||||
visible_lines = get_msg_window_lines(messages_win, packetlog_win)
|
||||
|
||||
@@ -1657,7 +1686,6 @@ def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: boo
|
||||
|
||||
refresh_message_highlight()
|
||||
|
||||
messages_win.refresh()
|
||||
refresh_pad(1)
|
||||
draw_packetlog_win()
|
||||
draw_window_arrows(1)
|
||||
@@ -1987,7 +2015,7 @@ def search(win: int) -> None:
|
||||
entry_win.erase()
|
||||
|
||||
|
||||
def refresh_pad(window: int) -> None:
|
||||
def refresh_pad(window: int, commit: bool = True) -> None:
|
||||
|
||||
# If in single-pane mode and this isn't the focused window, skip refreshing its (collapsed) pad
|
||||
if ui_state.single_pane_mode and window != ui_state.current_window:
|
||||
@@ -2004,7 +2032,7 @@ def refresh_pad(window: int) -> None:
|
||||
|
||||
if ui_state.display_log:
|
||||
packetlog_win.box()
|
||||
packetlog_win.refresh()
|
||||
packetlog_win.noutrefresh()
|
||||
|
||||
elif window == 2:
|
||||
pad = nodes_pad
|
||||
@@ -2047,9 +2075,12 @@ def refresh_pad(window: int) -> None:
|
||||
return
|
||||
|
||||
draw_frame_title(box, get_window_title(window))
|
||||
box.refresh()
|
||||
box.noutrefresh()
|
||||
|
||||
pad.refresh(
|
||||
# The frame overlaps the pad's viewport. Recompose even unchanged pad
|
||||
# rows before committing, so its text cannot flash blank between draws.
|
||||
pad.touchwin()
|
||||
pad.noutrefresh(
|
||||
start_index,
|
||||
0,
|
||||
top,
|
||||
@@ -2057,6 +2088,8 @@ def refresh_pad(window: int) -> None:
|
||||
bottom,
|
||||
right,
|
||||
)
|
||||
if commit:
|
||||
curses.doupdate()
|
||||
|
||||
|
||||
def add_notification(channel_number: int) -> None:
|
||||
|
||||
@@ -97,17 +97,6 @@ def get_translated_header(menu_path: List[str]) -> str:
|
||||
return f"{admin_target_label} | {breadcrumb}" if admin_target_label else breadcrumb
|
||||
|
||||
|
||||
def get_settings_option_label(option, field_info, menu_path):
|
||||
if menu_path == ["Main Menu", "Channels"] and isinstance(field_info, dict):
|
||||
name = field_info.get("name", (None, ""))[1]
|
||||
if name:
|
||||
return name
|
||||
full_key = ".".join(transform_menu_path(menu_path) + [option])
|
||||
if len(menu_path) == 3 and menu_path[1] == "Channels" and option == "enabled":
|
||||
return field_mapping.get(full_key, "Enabled")
|
||||
return field_mapping.get(full_key, option)
|
||||
|
||||
|
||||
def display_menu() -> tuple[object, object]:
|
||||
# if help_win:
|
||||
# min_help_window_height = 6
|
||||
@@ -151,7 +140,13 @@ def display_menu() -> tuple[object, object]:
|
||||
field_info = menu_state.current_menu[option]
|
||||
current_value = field_info[1] if isinstance(field_info, tuple) else ""
|
||||
full_key = ".".join(transformed_path + [option])
|
||||
display_name = get_settings_option_label(option, field_info, menu_state.menu_path)
|
||||
display_name = field_mapping.get(full_key, option)
|
||||
if menu_state.menu_path == ["Main Menu", "Channels"] and isinstance(field_info, dict):
|
||||
# Keep slot keys for navigation/saving, but label rows with the
|
||||
# configured name. Empty names retain their slot label.
|
||||
channel_name = field_info.get("name", (None, ""))[1]
|
||||
if isinstance(channel_name, str) and channel_name.strip():
|
||||
display_name = channel_name
|
||||
|
||||
if full_key.startswith("config.network.ipv4_config.") and option in {"ip", "gateway", "subnet", "dns"}:
|
||||
if isinstance(current_value, int):
|
||||
@@ -730,7 +725,7 @@ def settings_menu(
|
||||
full_key = ".".join(transformed_path + [selected_option])
|
||||
|
||||
# Fetch human-readable name from field_mapping
|
||||
human_readable_name = get_settings_option_label(selected_option, field_info, menu_state.menu_path)
|
||||
human_readable_name = field_mapping.get(full_key, selected_option)
|
||||
|
||||
if field is None and selected_option in ("ringtone", "messages"):
|
||||
is_ringtone = selected_option == "ringtone"
|
||||
@@ -840,7 +835,6 @@ def settings_menu(
|
||||
selected_option == "name"
|
||||
and len(menu_state.menu_path) == 3
|
||||
and menu_state.menu_path[1] == "Channels"
|
||||
and menu_state.menu_path[2] != "Channel 1"
|
||||
)
|
||||
new_value = get_text_input(
|
||||
f"{human_readable_name} is currently: {current_value}", selected_option, input_type,
|
||||
|
||||
+10
-5
@@ -1,8 +1,9 @@
|
||||
import curses
|
||||
import textwrap
|
||||
from contact.ui.colors import get_color
|
||||
|
||||
|
||||
def draw_splash(stdscr: object) -> None:
|
||||
def draw_splash(stdscr: object, status: str = "connecting...") -> None:
|
||||
"""Draw the splash screen with a logo and connecting message."""
|
||||
curses.curs_set(0)
|
||||
|
||||
@@ -13,16 +14,20 @@ def draw_splash(stdscr: object) -> None:
|
||||
message_1 = "/ Λ"
|
||||
message_2 = "/ / \\"
|
||||
message_3 = "P W R D"
|
||||
message_4 = "connecting..."
|
||||
|
||||
start_x = width // 2 - len(message_1) // 2
|
||||
start_x2 = width // 2 - len(message_4) // 2
|
||||
start_y = height // 2 - 1
|
||||
stdscr.addstr(start_y, start_x, message_1, get_color("splash_logo", bold=True))
|
||||
stdscr.addstr(start_y + 1, start_x - 1, message_2, get_color("splash_logo", bold=True))
|
||||
stdscr.addstr(start_y + 2, start_x - 2, message_3, get_color("splash_logo", bold=True))
|
||||
stdscr.addstr(start_y + 4, start_x2, message_4, get_color("splash_text"))
|
||||
stdscr.move(start_y + 5, start_x2)
|
||||
status_lines = []
|
||||
for line in status.splitlines():
|
||||
status_lines.extend(textwrap.wrap(line, max(1, width - 4)) or [""])
|
||||
for offset, line in enumerate(status_lines):
|
||||
row = start_y + 4 + offset
|
||||
if row >= height - 1:
|
||||
break
|
||||
stdscr.addstr(row, max(1, (width - len(line)) // 2), line, get_color("splash_text"))
|
||||
|
||||
stdscr.attrset(get_color("window_frame"))
|
||||
stdscr.box()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import logging
|
||||
import contextlib
|
||||
import io
|
||||
import time
|
||||
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
|
||||
|
||||
@@ -25,7 +27,7 @@ def interface_is_connected(interface) -> bool:
|
||||
return getattr(interface, "localNode", None) is not None
|
||||
|
||||
|
||||
def initialize_interface(args):
|
||||
def initialize_interface(args, status_callback=None):
|
||||
try:
|
||||
|
||||
if args.ble:
|
||||
@@ -43,7 +45,10 @@ def initialize_interface(args):
|
||||
logging.error(f"Error connecting to {args.host}. {ex}")
|
||||
else:
|
||||
try:
|
||||
client = meshtastic.serial_interface.SerialInterface(args.port)
|
||||
# The library prints its fallback notice directly to stdout,
|
||||
# which would overwrite the curses splash screen.
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
client = meshtastic.serial_interface.SerialInterface(args.port)
|
||||
except FileNotFoundError as ex:
|
||||
logging.error(f"The serial device at '{args.port}' was not found. {ex}")
|
||||
except PermissionError as ex:
|
||||
@@ -56,6 +61,8 @@ def initialize_interface(args):
|
||||
logging.error(f"The serial device couldn't be opened, it might be in use by another process. {ex}")
|
||||
if client.devPath is None:
|
||||
try:
|
||||
if status_callback:
|
||||
status_callback("No serial device found.\nConnecting via TCP to localhost…")
|
||||
client = meshtastic.tcp_interface.TCPInterface("localhost")
|
||||
except Exception as ex:
|
||||
logging.error(f"Error connecting to localhost:{ex}")
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import contact.ui.default_config as config
|
||||
|
||||
|
||||
def configure_logging(level: int) -> None:
|
||||
"""Set up file logging or exit with instructions for fixing its path."""
|
||||
try:
|
||||
logging.basicConfig(
|
||||
filename=config.log_file_path,
|
||||
level=level,
|
||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||
force=True,
|
||||
)
|
||||
except OSError as error:
|
||||
print(
|
||||
f"Unable to open log file: {os.path.abspath(config.log_file_path)}\n"
|
||||
f"{error}\n"
|
||||
f"Please edit config.json at: {os.path.abspath(config.json_file_path)}\n"
|
||||
'Set "log_file_path" to a file in an existing, writable directory, '
|
||||
"then restart Contact.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1) from None
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "contact"
|
||||
version = "1.6.7"
|
||||
version = "1.6.12"
|
||||
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
|
||||
authors = [
|
||||
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import unittest
|
||||
from contextlib import ExitStack
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
@@ -11,6 +12,52 @@ from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||
|
||||
|
||||
class ContactUiTests(unittest.TestCase):
|
||||
def test_f5_restores_background_when_switching_between_different_size_nodes(self):
|
||||
ui_state.node_list = [101, 102]
|
||||
ui_state.current_window = 2
|
||||
interface = SimpleNamespace(nodesByNum={
|
||||
101: {"user": {"shortName": "BIG", "longName": "A" * 60}, "position": {"altitude": 100}},
|
||||
102: {"user": {"shortName": "SMALL", "longName": "B"}},
|
||||
})
|
||||
win = mock.Mock()
|
||||
win.getch.side_effect = [contact_ui.curses.KEY_DOWN, contact_ui.curses.KEY_UP, 27]
|
||||
events = mock.Mock()
|
||||
events.attach_mock(win.noutrefresh, "dialog")
|
||||
stdscr = mock.Mock()
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(mock.patch.object(contact_ui.interface_state, "interface", interface))
|
||||
for name, value in (("LINES", 40), ("COLS", 100)):
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, name, value, create=True))
|
||||
for name in ("curs_set", "update_lines_cols", "doupdate"):
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, name))
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, "newwin", return_value=win))
|
||||
for name in ("get_color", "refresh_node_selection", "handle_resize"):
|
||||
stack.enter_context(mock.patch.object(contact_ui, name, return_value=0))
|
||||
restore = stack.enter_context(mock.patch.object(contact_ui, "stage_node_details_background"))
|
||||
events.attach_mock(restore, "restore")
|
||||
contact_ui.handle_f5_key(stdscr)
|
||||
self.assertEqual(events.mock_calls, [
|
||||
mock.call.dialog(), mock.call.restore(stdscr, 2), mock.call.dialog(),
|
||||
mock.call.restore(stdscr, 2), mock.call.dialog(),
|
||||
])
|
||||
self.assertNotEqual(win.resize.call_args_list[0], win.resize.call_args_list[1])
|
||||
self.assertEqual(win.refresh.call_count, 1) # Only on close.
|
||||
|
||||
def test_background_restore_stages_panes_without_flushing(self):
|
||||
ui_state.current_window = 4
|
||||
ui_state.single_pane_mode = True
|
||||
stdscr = mock.Mock()
|
||||
with ExitStack() as stack:
|
||||
for name in ("channel_win", "messages_win", "nodes_win", "entry_win"):
|
||||
stack.enter_context(mock.patch.object(contact_ui, name, mock.Mock(), create=True))
|
||||
refresh = stack.enter_context(mock.patch.object(contact_ui, "refresh_pad"))
|
||||
update = stack.enter_context(mock.patch.object(contact_ui.curses, "doupdate"))
|
||||
contact_ui.stage_node_details_background(stdscr, 2)
|
||||
refresh.assert_called_once_with(2, commit=False)
|
||||
update.assert_not_called()
|
||||
stdscr.noutrefresh.assert_called_once()
|
||||
self.assertEqual(ui_state.current_window, 4)
|
||||
|
||||
def setUp(self) -> None:
|
||||
reset_singletons()
|
||||
self.saved_config = snapshot_config("single_pane_mode")
|
||||
@@ -437,6 +484,8 @@ class ContactUiTests(unittest.TestCase):
|
||||
self.assertEqual(contact_ui.get_window_title(1), "Primary")
|
||||
|
||||
def test_refresh_pad_draws_selected_channel_title_on_message_frame(self) -> None:
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
mock.patch.object(contact_ui, "packetlog_win", mock.Mock(), create=True).start()
|
||||
ui_state.single_pane_mode = True
|
||||
ui_state.current_window = 1
|
||||
ui_state.channel_list = ["Primary"]
|
||||
@@ -453,9 +502,16 @@ class ContactUiTests(unittest.TestCase):
|
||||
contact_ui.messages_win.getmaxyx.return_value = (10, 20)
|
||||
|
||||
with mock.patch.object(contact_ui, "get_msg_window_lines", return_value=4):
|
||||
contact_ui.refresh_pad(1)
|
||||
with mock.patch.object(contact_ui.curses, "doupdate") as update:
|
||||
contact_ui.refresh_pad(1)
|
||||
|
||||
contact_ui.messages_win.addstr.assert_called_once_with(0, 2, " Primary ", contact_ui.curses.A_BOLD)
|
||||
contact_ui.messages_win.refresh.assert_not_called()
|
||||
contact_ui.messages_pad.refresh.assert_not_called()
|
||||
contact_ui.messages_win.noutrefresh.assert_called_once()
|
||||
contact_ui.messages_pad.touchwin.assert_called_once()
|
||||
contact_ui.messages_pad.noutrefresh.assert_called_once()
|
||||
update.assert_called_once()
|
||||
|
||||
def test_search_ignores_no_input_from_curses(self) -> None:
|
||||
ui_state.node_list = [101]
|
||||
@@ -508,6 +564,9 @@ class ContactUiTests(unittest.TestCase):
|
||||
contact_ui.handle_f5_key(stdscr)
|
||||
|
||||
self.assertEqual(dialog_win.getch.call_count, 2)
|
||||
# Idle polling must never present an erased dialog before its text.
|
||||
self.assertEqual(dialog_win.noutrefresh.call_count, 2)
|
||||
self.assertEqual(dialog_win.refresh.call_count, 1) # Closing the dialog only.
|
||||
handle_resize.assert_called_once_with(stdscr, False)
|
||||
|
||||
def test_f5_node_details_tolerates_none_metrics(self) -> None:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from argparse import Namespace
|
||||
from contextlib import ExitStack
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
@@ -10,6 +11,36 @@ from tests.test_support import reset_singletons
|
||||
|
||||
|
||||
class ControlUiTests(unittest.TestCase):
|
||||
def test_channel_list_renders_names_without_changing_slot_keys(self):
|
||||
from meshtastic.protobuf import channel_pb2, config_pb2, module_config_pb2
|
||||
from contact.ui.menus import generate_menu_from_protobuf
|
||||
from contact.utilities.singleton import menu_state
|
||||
|
||||
channels = [channel_pb2.Channel(settings=channel_pb2.ChannelSettings(name=name))
|
||||
for name in ("Local", "Local", "", " ")]
|
||||
node = SimpleNamespace(
|
||||
localConfig=config_pb2.Config(), moduleConfig=module_config_pb2.ModuleConfig(),
|
||||
getChannelByChannelIndex=lambda i: channels[i] if i < len(channels) else None,
|
||||
)
|
||||
menu_state.menu_path = ["Main Menu", "Channels"]
|
||||
menu_state.current_menu = generate_menu_from_protobuf(None, node=node)["Main Menu"]["Channels"]
|
||||
win, pad = mock.Mock(), mock.Mock()
|
||||
win.getbegyx.return_value = (2, 2)
|
||||
win.getmaxyx.return_value = (10, 80)
|
||||
with ExitStack() as stack:
|
||||
for name, value in (("LINES", 24), ("COLS", 100)):
|
||||
stack.enter_context(mock.patch.object(control_ui.curses, name, value, create=True))
|
||||
stack.enter_context(mock.patch.object(control_ui.curses, "newwin", return_value=win))
|
||||
stack.enter_context(mock.patch.object(control_ui.curses, "newpad", return_value=pad))
|
||||
stack.enter_context(mock.patch.object(control_ui.curses, "curs_set"))
|
||||
stack.enter_context(mock.patch.object(control_ui, "get_menu_width", return_value=80))
|
||||
for name in ("get_color", "draw_help_window", "draw_arrows"):
|
||||
stack.enter_context(mock.patch.object(control_ui, name, return_value=0))
|
||||
control_ui.display_menu()
|
||||
labels = [call.args[2].strip() for call in pad.addstr.call_args_list]
|
||||
self.assertEqual(labels, ["Local", "Local", "Channel 3", "Channel 4"])
|
||||
self.assertEqual(list(menu_state.current_menu), [f"Channel {i}" for i in range(1, 5)])
|
||||
|
||||
def setUp(self) -> None:
|
||||
reset_singletons()
|
||||
|
||||
|
||||
+1
-19
@@ -1,30 +1,12 @@
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from meshtastic.protobuf import channel_pb2, config_pb2, module_config_pb2
|
||||
from meshtastic.protobuf import config_pb2, module_config_pb2
|
||||
|
||||
from contact.ui.menus import generate_menu_from_protobuf
|
||||
|
||||
|
||||
class MenusTests(unittest.TestCase):
|
||||
def test_channels_include_enabled_state_and_display_names(self):
|
||||
from contact.ui.control_ui import get_settings_option_label
|
||||
|
||||
channels = [
|
||||
channel_pb2.Channel(role=channel_pb2.Channel.PRIMARY, settings=channel_pb2.ChannelSettings(name="Local")),
|
||||
channel_pb2.Channel(role=channel_pb2.Channel.DISABLED, settings=channel_pb2.ChannelSettings(name="Local")),
|
||||
channel_pb2.Channel(role=channel_pb2.Channel.DISABLED),
|
||||
]
|
||||
node = SimpleNamespace(
|
||||
localConfig=config_pb2.Config(), moduleConfig=module_config_pb2.ModuleConfig(),
|
||||
getChannelByChannelIndex=lambda index: channels[index] if index < len(channels) else None,
|
||||
)
|
||||
menu = generate_menu_from_protobuf(None, node=node)["Main Menu"]["Channels"]
|
||||
self.assertEqual(menu["Channel 1"]["enabled"], (None, True))
|
||||
self.assertEqual(menu["Channel 2"]["enabled"], (None, False))
|
||||
labels = [get_settings_option_label(key, value, ["Main Menu", "Channels"]) for key, value in menu.items()]
|
||||
self.assertEqual(labels, ["Local", "Local", "Channel 3"])
|
||||
|
||||
def test_main_menu_includes_factory_reset_config_after_factory_reset(self) -> None:
|
||||
local_node = SimpleNamespace(
|
||||
localConfig=config_pb2.Config(),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from contextlib import ExitStack
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import contact.ui.default_config
|
||||
from contact.utilities import input_handlers
|
||||
from tests.test_support import reset_singletons
|
||||
|
||||
|
||||
class RestoredSettingsTests(unittest.TestCase):
|
||||
def test_blank_input_is_unset_only_when_allowed(self):
|
||||
for keys, allowed, expected in [
|
||||
(["\n"], True, ""), ([" ", " ", "\n"], True, ""),
|
||||
(["\x1b"], True, None), (["\n", "\x1b"], False, None),
|
||||
]:
|
||||
with self.subTest(keys=keys, allowed=allowed), ExitStack() as stack:
|
||||
reset_singletons()
|
||||
stack.callback(reset_singletons)
|
||||
win = mock.Mock()
|
||||
win.get_wch.side_effect = keys
|
||||
stack.enter_context(mock.patch.object(input_handlers.curses, "newwin", return_value=win))
|
||||
for name, value in (("LINES", 24), ("COLS", 80)):
|
||||
stack.enter_context(mock.patch.object(input_handlers.curses, name, value, create=True))
|
||||
stack.enter_context(mock.patch.object(input_handlers.curses, "curs_set"))
|
||||
stack.enter_context(mock.patch.object(input_handlers, "get_color", return_value=0))
|
||||
invalid = stack.enter_context(mock.patch.object(input_handlers, "invalid_input"))
|
||||
result = input_handlers.get_text_input("Channel name", "name", str, allow_empty=allowed)
|
||||
self.assertEqual(result, expected)
|
||||
self.assertEqual(invalid.call_count, 0 if allowed else 1)
|
||||
|
||||
def test_missing_log_path_shows_configuration_guidance(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory).resolve()
|
||||
config_path = root / "config.json"
|
||||
log_path = root / "missing" / "client.log"
|
||||
for module in ("contact", "contact.settings"):
|
||||
with self.subTest(module=module):
|
||||
config_path.write_text(json.dumps({"log_file_path": str(log_path)}))
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", module],
|
||||
env={**os.environ, "CONTACT_CONFIG_ROOT": str(root)},
|
||||
cwd=Path(__file__).resolve().parents[1],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn(str(log_path), result.stderr)
|
||||
self.assertIn(f"Please edit config.json at: {config_path}", result.stderr)
|
||||
self.assertIn('"log_file_path"', result.stderr)
|
||||
self.assertNotIn("Traceback", result.stdout + result.stderr)
|
||||
Reference in new issue
Block a user