mirror of
https://github.com/pdxlocations/contact.git
synced 2026-03-28 17:12:35 +01:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b53dab1840 | ||
|
|
f2904eb550 | ||
|
|
480c32ba56 | ||
|
|
b4b084b627 | ||
|
|
5940c9b02b | ||
|
|
c492c96685 | ||
|
|
90376d35f3 |
@@ -12,6 +12,7 @@ Reboot, "Reboot", ""
|
||||
Reset Node DB, "Reset Node DB", ""
|
||||
Shutdown, "Shutdown", ""
|
||||
Factory Reset, "Factory Reset", ""
|
||||
factory_reset_config, "Factory Reset Config", ""
|
||||
Exit, "Exit", ""
|
||||
Yes, "Yes", ""
|
||||
No, "No", ""
|
||||
@@ -55,6 +56,7 @@ confirm.reboot, "Are you sure you want to Reboot?", ""
|
||||
confirm.reset_node_db, "Are you sure you want to Reset Node DB?", ""
|
||||
confirm.shutdown, "Are you sure you want to Shutdown?", ""
|
||||
confirm.factory_reset, "Are you sure you want to Factory Reset?", ""
|
||||
confirm.factory_reset_config, "Are you sure you want to Factory Reset Config?", ""
|
||||
confirm.save_before_exit_section, "You have unsaved changes in {section}. Save before exiting?", ""
|
||||
prompt.select_region, "Select your region:", ""
|
||||
dialog.slow_down_title, "Slow down", ""
|
||||
|
||||
@@ -12,6 +12,7 @@ Reboot, "Перезагрузить", ""
|
||||
Reset Node DB, "Сбросить БД узлов", ""
|
||||
Shutdown, "Выключить", ""
|
||||
Factory Reset, "Сброс до заводских", ""
|
||||
factory_reset_config, "Сбросить только конфигурацию", ""
|
||||
Exit, "Выход", ""
|
||||
Yes, "Да", ""
|
||||
No, "Нет", ""
|
||||
@@ -55,6 +56,7 @@ confirm.reboot, "Перезагрузить устройство?", ""
|
||||
confirm.reset_node_db, "Сбросить БД узлов?", ""
|
||||
confirm.shutdown, "Выключить устройство?", ""
|
||||
confirm.factory_reset, "Сбросить до заводских настроек?", ""
|
||||
confirm.factory_reset_config, "Сбросить только конфигурацию?", ""
|
||||
confirm.save_before_exit_section, "Есть несохраненные изменения в {section}. Сохранить перед выходом?", ""
|
||||
prompt.select_region, "Выберите ваш регион:", ""
|
||||
dialog.slow_down_title, "Подождите", ""
|
||||
|
||||
@@ -6,19 +6,26 @@ import sys
|
||||
import traceback
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
from contact.utilities.i18n import t
|
||||
from contact.ui.dialog import dialog
|
||||
from contact.utilities.i18n import t
|
||||
from contact.ui.colors import setup_colors
|
||||
from contact.ui.splash import draw_splash
|
||||
from contact.ui.control_ui import set_region, settings_menu
|
||||
from contact.ui.dialog import dialog
|
||||
from contact.ui.splash import draw_splash
|
||||
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
|
||||
|
||||
|
||||
def close_interface(interface: object) -> None:
|
||||
if interface is None:
|
||||
return
|
||||
with contextlib.suppress(Exception):
|
||||
interface.close()
|
||||
|
||||
|
||||
def main(stdscr: curses.window) -> None:
|
||||
output_capture = io.StringIO()
|
||||
interface = None
|
||||
try:
|
||||
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
|
||||
setup_colors()
|
||||
@@ -39,7 +46,7 @@ def main(stdscr: curses.window) -> None:
|
||||
)
|
||||
if confirmation == "Yes":
|
||||
set_region(interface)
|
||||
interface.close()
|
||||
close_interface(interface)
|
||||
draw_splash(stdscr)
|
||||
interface = reconnect_interface(args)
|
||||
stdscr.clear()
|
||||
@@ -52,6 +59,8 @@ def main(stdscr: curses.window) -> None:
|
||||
logging.error("Traceback: %s", traceback.format_exc())
|
||||
logging.error("Console output before crash:\n%s", console_output)
|
||||
raise
|
||||
finally:
|
||||
close_interface(interface)
|
||||
|
||||
|
||||
def ensure_min_rows(stdscr: curses.window, min_rows: int = 11) -> None:
|
||||
|
||||
@@ -143,7 +143,7 @@ def refresh_node_selection(old_index: int = -1, highlight: bool = False) -> None
|
||||
if nodes_pad is None or not ui_state.node_list:
|
||||
return
|
||||
|
||||
width = max(0, nodes_pad.getmaxyx()[1] - 2)
|
||||
width = max(0, nodes_pad.getmaxyx()[1] - 4)
|
||||
|
||||
if 0 <= old_index < len(ui_state.node_list):
|
||||
try:
|
||||
@@ -175,7 +175,7 @@ def refresh_main_window(window_id: int, selected: bool) -> None:
|
||||
elif window_id == 2:
|
||||
paint_frame(nodes_win, selected=selected)
|
||||
if ui_state.node_list and nodes_pad is not None:
|
||||
width = max(0, nodes_pad.getmaxyx()[1] - 2)
|
||||
width = max(0, nodes_pad.getmaxyx()[1] - 4)
|
||||
nodes_pad.chgat(ui_state.selected_node, 1, width, get_node_row_color(ui_state.selected_node))
|
||||
refresh_pad(2)
|
||||
|
||||
@@ -185,6 +185,43 @@ def get_node_display_name(node_num: int, node: dict) -> str:
|
||||
return user.get("longName") or get_name_from_database(node_num, "long")
|
||||
|
||||
|
||||
def get_selected_channel_title() -> str:
|
||||
if not ui_state.channel_list:
|
||||
return ""
|
||||
|
||||
channel = ui_state.channel_list[min(ui_state.selected_channel, len(ui_state.channel_list) - 1)]
|
||||
if isinstance(channel, int):
|
||||
return get_name_from_database(channel, "long") or get_name_from_database(channel, "short") or str(channel)
|
||||
return str(channel)
|
||||
|
||||
|
||||
def get_window_title(window: int) -> str:
|
||||
if window == 2:
|
||||
return f"Nodes: {len(ui_state.node_list)}"
|
||||
if ui_state.single_pane_mode and window == 1:
|
||||
return get_selected_channel_title()
|
||||
return ""
|
||||
|
||||
|
||||
def draw_frame_title(box: curses.window, title: str) -> None:
|
||||
if not title:
|
||||
return
|
||||
|
||||
_, box_w = box.getmaxyx()
|
||||
max_title_width = max(0, box_w - 6)
|
||||
if max_title_width <= 0:
|
||||
return
|
||||
|
||||
clipped_title = truncate_with_ellipsis(title, max_title_width).rstrip()
|
||||
if not clipped_title:
|
||||
return
|
||||
|
||||
try:
|
||||
box.addstr(0, 2, f" {clipped_title} ", curses.A_BOLD)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
|
||||
def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||
"""Handle terminal resize events and redraw the UI accordingly."""
|
||||
global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, packetlog_win, entry_win
|
||||
@@ -193,11 +230,19 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||
height, width = stdscr.getmaxyx()
|
||||
|
||||
if ui_state.single_pane_mode:
|
||||
channel_width, messages_width, nodes_width = compute_widths(width, ui_state.current_window)
|
||||
channel_width = width
|
||||
messages_width = width
|
||||
nodes_width = width
|
||||
channel_x = 0
|
||||
messages_x = 0
|
||||
nodes_x = 0
|
||||
else:
|
||||
channel_width = int(config.channel_list_16ths) * (width // 16)
|
||||
nodes_width = int(config.node_list_16ths) * (width // 16)
|
||||
messages_width = width - channel_width - nodes_width
|
||||
channel_x = 0
|
||||
messages_x = channel_width
|
||||
nodes_x = channel_width + messages_width
|
||||
|
||||
channel_width = max(MIN_COL, channel_width)
|
||||
messages_width = max(MIN_COL, messages_width)
|
||||
@@ -205,7 +250,7 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||
|
||||
# Ensure the three widths sum exactly to the terminal width by adjusting the focused pane
|
||||
total = channel_width + messages_width + nodes_width
|
||||
if total != width:
|
||||
if not ui_state.single_pane_mode and total != width:
|
||||
delta = total - width
|
||||
if ui_state.current_window == 0:
|
||||
channel_width = max(MIN_COL, channel_width - delta)
|
||||
@@ -222,11 +267,11 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||
if firstrun:
|
||||
entry_win = curses.newwin(entry_height, width, height - entry_height, 0)
|
||||
|
||||
channel_win = curses.newwin(content_h, channel_width, 0, 0)
|
||||
messages_win = curses.newwin(content_h, messages_width, 0, channel_width)
|
||||
nodes_win = curses.newwin(content_h, nodes_width, 0, channel_width + messages_width)
|
||||
channel_win = curses.newwin(content_h, channel_width, 0, channel_x)
|
||||
messages_win = curses.newwin(content_h, messages_width, 0, messages_x)
|
||||
nodes_win = curses.newwin(content_h, nodes_width, 0, nodes_x)
|
||||
|
||||
packetlog_win = curses.newwin(pkt_h, messages_width, height - pkt_h - entry_height, channel_width)
|
||||
packetlog_win = curses.newwin(pkt_h, messages_width, height - pkt_h - entry_height, messages_x)
|
||||
|
||||
# Will be resized to what we need when drawn
|
||||
messages_pad = curses.newpad(1, 1)
|
||||
@@ -253,19 +298,25 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||
entry_win.mvwin(height - entry_height, 0)
|
||||
|
||||
channel_win.resize(content_h, channel_width)
|
||||
channel_win.mvwin(0, 0)
|
||||
channel_win.mvwin(0, channel_x)
|
||||
|
||||
messages_win.resize(content_h, messages_width)
|
||||
messages_win.mvwin(0, channel_width)
|
||||
messages_win.mvwin(0, messages_x)
|
||||
|
||||
nodes_win.resize(content_h, nodes_width)
|
||||
nodes_win.mvwin(0, channel_width + messages_width)
|
||||
nodes_win.mvwin(0, nodes_x)
|
||||
|
||||
packetlog_win.resize(pkt_h, messages_width)
|
||||
packetlog_win.mvwin(height - pkt_h - entry_height, channel_width)
|
||||
packetlog_win.mvwin(height - pkt_h - entry_height, messages_x)
|
||||
|
||||
# Draw window borders
|
||||
for win in [channel_win, entry_win, nodes_win, messages_win]:
|
||||
windows_to_draw = [entry_win]
|
||||
if ui_state.single_pane_mode:
|
||||
windows_to_draw.append([channel_win, messages_win, nodes_win][ui_state.current_window])
|
||||
else:
|
||||
windows_to_draw.extend([channel_win, nodes_win, messages_win])
|
||||
|
||||
for win in windows_to_draw:
|
||||
win.box()
|
||||
win.refresh()
|
||||
|
||||
@@ -1038,7 +1089,7 @@ def draw_channel_list() -> None:
|
||||
notification = " " + config.notification_symbol if idx in ui_state.notifications else ""
|
||||
|
||||
# Truncate the channel name if it's too long to fit in the window
|
||||
truncated_channel = truncate_with_ellipsis(f"{channel}{notification}", win_width - 3)
|
||||
truncated_channel = truncate_with_ellipsis(f"{channel}{notification}", win_width - 4)
|
||||
|
||||
color = get_color("channel_list")
|
||||
if idx == ui_state.selected_channel:
|
||||
@@ -1133,7 +1184,7 @@ def draw_node_list() -> None:
|
||||
node_name = get_node_display_name(node_num, node)
|
||||
|
||||
# Future node name custom formatting possible
|
||||
node_str = pad_to_width(f"{status_icon} {node_name}", box_width - 2)
|
||||
node_str = truncate_with_ellipsis(f"{status_icon} {node_name}", box_width - 4)
|
||||
nodes_pad.addstr(i, 1, node_str, get_node_row_color(i))
|
||||
|
||||
paint_frame(nodes_win, selected=(ui_state.current_window == 2))
|
||||
@@ -1365,7 +1416,6 @@ def refresh_pad(window: int) -> None:
|
||||
pad = nodes_pad
|
||||
box = nodes_win
|
||||
lines = box.getmaxyx()[0] - 2
|
||||
box.addstr(0, 2, (f"Nodes: {len(ui_state.node_list)}"), curses.A_BOLD)
|
||||
selected_item = ui_state.selected_node
|
||||
start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders
|
||||
|
||||
@@ -1402,6 +1452,9 @@ def refresh_pad(window: int) -> None:
|
||||
if bottom < top or right < left:
|
||||
return
|
||||
|
||||
draw_frame_title(box, get_window_title(window))
|
||||
box.refresh()
|
||||
|
||||
pad.refresh(
|
||||
start_index,
|
||||
0,
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import List
|
||||
from meshtastic.protobuf import admin_pb2
|
||||
|
||||
from contact.utilities.save_to_radio import save_changes
|
||||
import contact.ui.default_config as config
|
||||
@@ -34,7 +35,7 @@ MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
|
||||
save_option = "Save Changes"
|
||||
max_help_lines = 0
|
||||
help_win = None
|
||||
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
|
||||
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset", "factory_reset_config"]
|
||||
|
||||
|
||||
# Compute the effective menu width for the current terminal
|
||||
@@ -43,7 +44,7 @@ def get_menu_width() -> int:
|
||||
return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2))
|
||||
|
||||
|
||||
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
|
||||
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset", "factory_reset_config"]
|
||||
|
||||
# Get the parent directory of the script
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -246,6 +247,26 @@ def reconnect_after_admin_action(stdscr: object, interface: object, action, log_
|
||||
return reconnect_interface_with_splash(stdscr, interface)
|
||||
|
||||
|
||||
def request_factory_reset(node: object, full: bool = False):
|
||||
try:
|
||||
return node.factoryReset(full=full)
|
||||
except TypeError as ex:
|
||||
field_name = "factory_reset_device" if full else "factory_reset_config"
|
||||
field = admin_pb2.AdminMessage.DESCRIPTOR.fields_by_name[field_name]
|
||||
if field.cpp_type != field.CPPTYPE_INT32:
|
||||
raise
|
||||
|
||||
node.ensureSessionKey()
|
||||
message = admin_pb2.AdminMessage()
|
||||
setattr(message, field_name, 1)
|
||||
|
||||
if node == node.iface.localNode:
|
||||
on_response = None
|
||||
else:
|
||||
on_response = node.onAckNak
|
||||
return node._sendAdmin(message, onResponse=on_response)
|
||||
|
||||
|
||||
def redraw_main_ui_after_reconnect(stdscr: object) -> None:
|
||||
try:
|
||||
from contact.ui import contact_ui
|
||||
@@ -538,7 +559,27 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
)
|
||||
if confirmation == "Yes":
|
||||
interface = reconnect_after_admin_action(
|
||||
stdscr, interface, interface.localNode.factoryReset, "Factory Reset Requested by menu"
|
||||
stdscr,
|
||||
interface,
|
||||
lambda: request_factory_reset(interface.localNode, full=True),
|
||||
"Factory Reset Requested by menu",
|
||||
)
|
||||
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||
menu_state.start_index.pop()
|
||||
continue
|
||||
|
||||
elif selected_option == "factory_reset_config":
|
||||
confirmation = get_list_input(
|
||||
t("ui.confirm.factory_reset_config", default="Are you sure you want to Factory Reset Config?"),
|
||||
None,
|
||||
["Yes", "No"],
|
||||
)
|
||||
if confirmation == "Yes":
|
||||
interface = reconnect_after_admin_action(
|
||||
stdscr,
|
||||
interface,
|
||||
lambda: request_factory_reset(interface.localNode, full=False),
|
||||
"Factory Reset Config Requested by menu",
|
||||
)
|
||||
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||
menu_state.start_index.pop()
|
||||
|
||||
@@ -133,6 +133,7 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
|
||||
"Reset Node DB": None,
|
||||
"Shutdown": None,
|
||||
"Factory Reset": None,
|
||||
"factory_reset_config": None,
|
||||
"Exit": None,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -457,8 +457,8 @@ def highlight_line(
|
||||
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, color_new)
|
||||
|
||||
elif ui_state.current_window == 2:
|
||||
menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 2, get_node_color(old_idx))
|
||||
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 2, get_node_color(new_idx, reverse=True))
|
||||
menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(old_idx))
|
||||
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(new_idx, reverse=True))
|
||||
|
||||
menu_win.refresh()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "contact"
|
||||
version = "1.5.2"
|
||||
version = "1.5.4"
|
||||
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"}
|
||||
|
||||
@@ -3,6 +3,7 @@ from unittest import mock
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.ui import contact_ui
|
||||
from contact.ui.nav_utils import text_width
|
||||
from contact.utilities.singleton import ui_state
|
||||
|
||||
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||
@@ -69,7 +70,7 @@ class ContactUiTests(unittest.TestCase):
|
||||
self.assertFalse(ui_state.redraw_channels)
|
||||
self.assertFalse(ui_state.redraw_messages)
|
||||
|
||||
def test_refresh_node_selection_highlights_full_row_width(self) -> None:
|
||||
def test_refresh_node_selection_reserves_scroll_arrow_column(self) -> None:
|
||||
ui_state.node_list = [101, 202]
|
||||
ui_state.selected_node = 1
|
||||
ui_state.start_index = [0, 0, 0]
|
||||
@@ -89,7 +90,113 @@ class ContactUiTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(
|
||||
contact_ui.nodes_pad.chgat.call_args_list,
|
||||
[mock.call(0, 1, 18, 11), mock.call(1, 1, 18, 22)],
|
||||
[mock.call(0, 1, 16, 11), mock.call(1, 1, 16, 22)],
|
||||
)
|
||||
refresh_pad.assert_called_once_with(2)
|
||||
draw_window_arrows.assert_called_once_with(2)
|
||||
|
||||
def test_draw_channel_list_reserves_scroll_arrow_column(self) -> None:
|
||||
ui_state.channel_list = ["VeryLongChannelName"]
|
||||
ui_state.notifications = []
|
||||
ui_state.selected_channel = 0
|
||||
ui_state.current_window = 0
|
||||
contact_ui.channel_pad = mock.Mock()
|
||||
contact_ui.channel_win = mock.Mock()
|
||||
contact_ui.channel_win.getmaxyx.return_value = (10, 20)
|
||||
|
||||
with mock.patch.object(contact_ui, "get_color", return_value=1):
|
||||
with mock.patch.object(contact_ui, "paint_frame"):
|
||||
with mock.patch.object(contact_ui, "refresh_pad"):
|
||||
with mock.patch.object(contact_ui, "draw_window_arrows"):
|
||||
with mock.patch.object(contact_ui, "remove_notification"):
|
||||
contact_ui.draw_channel_list()
|
||||
|
||||
text = contact_ui.channel_pad.addstr.call_args.args[2]
|
||||
self.assertEqual(len(text), 16)
|
||||
|
||||
def test_draw_node_list_reserves_scroll_arrow_column(self) -> None:
|
||||
ui_state.node_list = [101]
|
||||
ui_state.current_window = 2
|
||||
contact_ui.nodes_pad = mock.Mock()
|
||||
contact_ui.nodes_win = mock.Mock()
|
||||
contact_ui.nodes_win.getmaxyx.return_value = (10, 20)
|
||||
contact_ui.entry_win = mock.Mock()
|
||||
interface = mock.Mock()
|
||||
interface.nodesByNum = {101: {"user": {"longName": "VeryLongNodeName", "publicKey": ""}}}
|
||||
|
||||
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
|
||||
with mock.patch.object(contact_ui, "get_node_row_color", return_value=1):
|
||||
with mock.patch.object(contact_ui.curses, "curs_set"):
|
||||
with mock.patch.object(contact_ui, "paint_frame"):
|
||||
with mock.patch.object(contact_ui, "refresh_pad"):
|
||||
with mock.patch.object(contact_ui, "draw_window_arrows"):
|
||||
contact_ui.draw_node_list()
|
||||
|
||||
text = contact_ui.nodes_pad.addstr.call_args.args[2]
|
||||
self.assertEqual(text_width(text), 16)
|
||||
self.assertIn("…", text)
|
||||
|
||||
def test_handle_resize_single_pane_keeps_full_width_windows(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
stdscr.getmaxyx.return_value = (24, 80)
|
||||
ui_state.single_pane_mode = True
|
||||
ui_state.current_window = 1
|
||||
|
||||
contact_ui.entry_win = mock.Mock()
|
||||
contact_ui.channel_win = mock.Mock()
|
||||
contact_ui.messages_win = mock.Mock()
|
||||
contact_ui.nodes_win = mock.Mock()
|
||||
contact_ui.packetlog_win = mock.Mock()
|
||||
contact_ui.messages_pad = mock.Mock()
|
||||
contact_ui.nodes_pad = mock.Mock()
|
||||
contact_ui.channel_pad = mock.Mock()
|
||||
|
||||
with mock.patch.object(contact_ui.curses, "curs_set"):
|
||||
with mock.patch.object(contact_ui, "draw_channel_list") as draw_channel_list:
|
||||
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
|
||||
with mock.patch.object(contact_ui, "draw_node_list") as draw_node_list:
|
||||
with mock.patch.object(contact_ui, "draw_window_arrows") as draw_window_arrows:
|
||||
contact_ui.handle_resize(stdscr, False)
|
||||
|
||||
contact_ui.channel_win.resize.assert_called_once_with(21, 80)
|
||||
contact_ui.messages_win.resize.assert_called_once_with(21, 80)
|
||||
contact_ui.nodes_win.resize.assert_called_once_with(21, 80)
|
||||
contact_ui.channel_win.mvwin.assert_called_once_with(0, 0)
|
||||
contact_ui.messages_win.mvwin.assert_called_once_with(0, 0)
|
||||
contact_ui.nodes_win.mvwin.assert_called_once_with(0, 0)
|
||||
contact_ui.channel_win.box.assert_not_called()
|
||||
contact_ui.nodes_win.box.assert_not_called()
|
||||
contact_ui.messages_win.box.assert_called_once_with()
|
||||
draw_channel_list.assert_called_once_with()
|
||||
draw_messages_window.assert_called_once_with(True)
|
||||
draw_node_list.assert_called_once_with()
|
||||
draw_window_arrows.assert_called_once_with(1)
|
||||
|
||||
def test_get_window_title_uses_selected_channel_only_for_messages_in_single_pane_mode(self) -> None:
|
||||
ui_state.single_pane_mode = True
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.selected_channel = 0
|
||||
|
||||
self.assertEqual(contact_ui.get_window_title(0), "")
|
||||
self.assertEqual(contact_ui.get_window_title(1), "Primary")
|
||||
|
||||
def test_refresh_pad_draws_selected_channel_title_on_message_frame(self) -> None:
|
||||
ui_state.single_pane_mode = True
|
||||
ui_state.current_window = 1
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.selected_channel = 0
|
||||
ui_state.start_index = [0, 0, 0]
|
||||
ui_state.display_log = False
|
||||
|
||||
contact_ui.channel_win = mock.Mock()
|
||||
contact_ui.channel_win.getmaxyx.return_value = (10, 20)
|
||||
contact_ui.messages_pad = mock.Mock()
|
||||
contact_ui.messages_pad.getmaxyx.return_value = (5, 20)
|
||||
contact_ui.messages_win = mock.Mock()
|
||||
contact_ui.messages_win.getbegyx.return_value = (0, 0)
|
||||
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)
|
||||
|
||||
contact_ui.messages_win.addstr.assert_called_once_with(0, 2, " Primary ", contact_ui.curses.A_BOLD)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from argparse import Namespace
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
@@ -63,3 +64,49 @@ class ControlUiTests(unittest.TestCase):
|
||||
get_channels.assert_called_once_with()
|
||||
refresh_node_list.assert_called_once_with()
|
||||
handle_resize.assert_called_once_with(stdscr, False)
|
||||
|
||||
def test_request_factory_reset_uses_library_helper_when_supported(self) -> None:
|
||||
node = mock.Mock()
|
||||
|
||||
control_ui.request_factory_reset(node)
|
||||
|
||||
node.factoryReset.assert_called_once_with(full=False)
|
||||
node.ensureSessionKey.assert_not_called()
|
||||
node._sendAdmin.assert_not_called()
|
||||
|
||||
def test_request_factory_reset_uses_library_helper_for_full_reset_when_supported(self) -> None:
|
||||
node = mock.Mock()
|
||||
|
||||
control_ui.request_factory_reset(node, full=True)
|
||||
|
||||
node.factoryReset.assert_called_once_with(full=True)
|
||||
node.ensureSessionKey.assert_not_called()
|
||||
node._sendAdmin.assert_not_called()
|
||||
|
||||
def test_request_factory_reset_falls_back_to_int_valued_admin_message(self) -> None:
|
||||
node = mock.Mock()
|
||||
node.factoryReset.side_effect = TypeError(
|
||||
"Field meshtastic.protobuf.AdminMessage.factory_reset_config: Expected an int, got a boolean."
|
||||
)
|
||||
node.iface = SimpleNamespace(localNode=node)
|
||||
|
||||
control_ui.request_factory_reset(node)
|
||||
|
||||
node.ensureSessionKey.assert_called_once_with()
|
||||
sent_message = node._sendAdmin.call_args.args[0]
|
||||
self.assertEqual(sent_message.factory_reset_config, 1)
|
||||
self.assertIsNone(node._sendAdmin.call_args.kwargs["onResponse"])
|
||||
|
||||
def test_request_factory_reset_full_falls_back_to_int_valued_admin_message(self) -> None:
|
||||
node = mock.Mock()
|
||||
node.factoryReset.side_effect = TypeError(
|
||||
"Field meshtastic.protobuf.AdminMessage.factory_reset_device: Expected an int, got a boolean."
|
||||
)
|
||||
node.iface = SimpleNamespace(localNode=node)
|
||||
|
||||
control_ui.request_factory_reset(node, full=True)
|
||||
|
||||
node.ensureSessionKey.assert_called_once_with()
|
||||
sent_message = node._sendAdmin.call_args.args[0]
|
||||
self.assertEqual(sent_message.factory_reset_device, 1)
|
||||
self.assertIsNone(node._sendAdmin.call_args.kwargs["onResponse"])
|
||||
|
||||
28
tests/test_menus.py
Normal file
28
tests/test_menus.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from meshtastic.protobuf import config_pb2, module_config_pb2
|
||||
|
||||
from contact.ui.menus import generate_menu_from_protobuf
|
||||
|
||||
|
||||
class MenusTests(unittest.TestCase):
|
||||
def test_main_menu_includes_factory_reset_config_after_factory_reset(self) -> None:
|
||||
local_node = SimpleNamespace(
|
||||
localConfig=config_pb2.Config(),
|
||||
moduleConfig=module_config_pb2.ModuleConfig(),
|
||||
getChannelByChannelIndex=lambda _: None,
|
||||
)
|
||||
interface = SimpleNamespace(
|
||||
localNode=local_node,
|
||||
getMyNodeInfo=lambda: {
|
||||
"user": {"longName": "Test User", "shortName": "TU", "isLicensed": False},
|
||||
"position": {"latitude": 0.0, "longitude": 0.0, "altitude": 0},
|
||||
},
|
||||
)
|
||||
|
||||
menu = generate_menu_from_protobuf(interface)
|
||||
keys = list(menu["Main Menu"].keys())
|
||||
|
||||
self.assertLess(keys.index("Factory Reset"), keys.index("factory_reset_config"))
|
||||
self.assertEqual(keys[keys.index("Factory Reset") + 1], "factory_reset_config")
|
||||
@@ -18,7 +18,7 @@ class NavUtilsTests(unittest.TestCase):
|
||||
def test_truncate_with_ellipsis_respects_display_width(self) -> None:
|
||||
self.assertEqual(truncate_with_ellipsis("🔐Alpha", 5), "🔐Al…")
|
||||
|
||||
def test_highlight_line_uses_full_node_row_width(self) -> None:
|
||||
def test_highlight_line_reserves_scroll_arrow_column_for_nodes(self) -> None:
|
||||
ui_state.current_window = 2
|
||||
ui_state.start_index = [0, 0, 0]
|
||||
menu_win = mock.Mock()
|
||||
@@ -32,5 +32,5 @@ class NavUtilsTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(
|
||||
menu_pad.chgat.call_args_list,
|
||||
[mock.call(0, 1, 18, 11), mock.call(1, 1, 18, 22)],
|
||||
[mock.call(0, 1, 16, 11), mock.call(1, 1, 16, 22)],
|
||||
)
|
||||
|
||||
75
tests/test_settings.py
Normal file
75
tests/test_settings.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from argparse import Namespace
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import contact.settings as settings
|
||||
|
||||
|
||||
class SettingsRuntimeTests(unittest.TestCase):
|
||||
def test_main_closes_interface_after_normal_settings_exit(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
args = Namespace()
|
||||
interface = mock.Mock()
|
||||
interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=1)))
|
||||
|
||||
with mock.patch.object(settings, "setup_colors"):
|
||||
with mock.patch.object(settings, "ensure_min_rows"):
|
||||
with mock.patch.object(settings, "draw_splash"):
|
||||
with mock.patch.object(settings.curses, "curs_set"):
|
||||
with mock.patch.object(settings, "setup_parser") as setup_parser:
|
||||
with mock.patch.object(settings, "initialize_interface", return_value=interface):
|
||||
with mock.patch.object(settings, "settings_menu") as settings_menu:
|
||||
setup_parser.return_value.parse_args.return_value = args
|
||||
settings.main(stdscr)
|
||||
|
||||
settings_menu.assert_called_once_with(stdscr, interface)
|
||||
interface.close.assert_called_once_with()
|
||||
|
||||
def test_main_closes_reconnected_interface_after_region_reset(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
args = Namespace()
|
||||
old_interface = mock.Mock()
|
||||
old_interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=0)))
|
||||
new_interface = mock.Mock()
|
||||
new_interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=1)))
|
||||
|
||||
with mock.patch.object(settings, "setup_colors"):
|
||||
with mock.patch.object(settings, "ensure_min_rows"):
|
||||
with mock.patch.object(settings, "draw_splash"):
|
||||
with mock.patch.object(settings.curses, "curs_set"):
|
||||
with mock.patch.object(settings, "setup_parser") as setup_parser:
|
||||
with mock.patch.object(settings, "initialize_interface", return_value=old_interface):
|
||||
with mock.patch.object(settings, "get_list_input", return_value="Yes"):
|
||||
with mock.patch.object(settings, "set_region") as set_region:
|
||||
with mock.patch.object(
|
||||
settings, "reconnect_interface", return_value=new_interface
|
||||
) as reconnect_interface:
|
||||
with mock.patch.object(settings, "settings_menu") as settings_menu:
|
||||
setup_parser.return_value.parse_args.return_value = args
|
||||
settings.main(stdscr)
|
||||
|
||||
set_region.assert_called_once_with(old_interface)
|
||||
reconnect_interface.assert_called_once_with(args)
|
||||
settings_menu.assert_called_once_with(stdscr, new_interface)
|
||||
old_interface.close.assert_called_once_with()
|
||||
new_interface.close.assert_called_once_with()
|
||||
|
||||
def test_main_closes_interface_when_settings_menu_raises(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
args = Namespace()
|
||||
interface = mock.Mock()
|
||||
interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=1)))
|
||||
|
||||
with mock.patch.object(settings, "setup_colors"):
|
||||
with mock.patch.object(settings, "ensure_min_rows"):
|
||||
with mock.patch.object(settings, "draw_splash"):
|
||||
with mock.patch.object(settings.curses, "curs_set"):
|
||||
with mock.patch.object(settings, "setup_parser") as setup_parser:
|
||||
with mock.patch.object(settings, "initialize_interface", return_value=interface):
|
||||
with mock.patch.object(settings, "settings_menu", side_effect=RuntimeError("boom")):
|
||||
setup_parser.return_value.parse_args.return_value = args
|
||||
with self.assertRaises(RuntimeError):
|
||||
settings.main(stdscr)
|
||||
|
||||
interface.close.assert_called_once_with()
|
||||
Reference in New Issue
Block a user