diff --git a/contact/localisations/en.ini b/contact/localisations/en.ini index b3deaa0..e29ad52 100644 --- a/contact/localisations/en.ini +++ b/contact/localisations/en.ini @@ -78,6 +78,7 @@ help.traceroute, "Ctrl+T or F4 = Traceroute", "" help.node_info, "F5 = Full node info", "" help.archive_chat, "Ctrl+D = Archive chat / remove node", "" help.favorite, "Ctrl+F = Favorite", "" +help.bot_responder, "Ctrl+B = Toggle Bot Responder", "" help.ignore, "Ctrl+G = Ignore", "" help.search, "Ctrl+/ or / = Search", "" help.help, "Ctrl+K = Help", "" @@ -90,6 +91,11 @@ confirm.remove_ignored, "Remove {name} from Ignored?", "" confirm.region_unset, "Your region is UNSET. Set it now?", "" dialog.resize_title, "Resize Terminal", "" dialog.resize_body, "Please resize the terminal to at least {rows} rows.", "" +bot.status.enabled, "Enabled", "" +bot.status.disabled, "Disabled", "" +bot.dialog.title, "Bot Responder", "" +bot.dialog.body, "Bot responder is now {status}.", "" +bot.status.message, "Bot responder is now {status}.", "" bot.catch_words, "ping; test", "Semicolon-separated bot trigger words." bot.response.word, "Pong!", "Bot response word." diff --git a/contact/localisations/fr.ini b/contact/localisations/fr.ini index 72e196c..f8cc042 100644 --- a/contact/localisations/fr.ini +++ b/contact/localisations/fr.ini @@ -78,10 +78,16 @@ help.traceroute, "Ctrl+T ou F4 = Traceroute", "" help.node_info, "F5 = Informations complètes du nœud", "" help.archive_chat, "Ctrl+D = Archiver la discussion / supprimer le nœud", "" help.favorite, "Ctrl+F = Favori", "" +help.bot_responder, "Ctrl+B = Activer/désactiver le bot répondeur", "" help.ignore, "Ctrl+G = Ignorer", "" help.search, "Ctrl+/ ou / = Rechercher", "" help.help, "Ctrl+K = Aide", "" help.no_help, "Aucune aide disponible.", "" +bot.status.enabled, "Activé", "" +bot.status.disabled, "Désactivé", "" +bot.dialog.title, "Bot répondeur", "" +bot.dialog.body, "Le bot répondeur est maintenant {status}.", "" +bot.status.message, "Le bot répondeur est maintenant {status}.", "" bot.catch_words, "ping", "Mots déclencheurs du bot séparés par des virgules." bot.response.word, "Pong!", "Mot de reponse du bot (orthographe preferee)." diff --git a/contact/localisations/ru.ini b/contact/localisations/ru.ini index bcf44ea..7c01ad4 100644 --- a/contact/localisations/ru.ini +++ b/contact/localisations/ru.ini @@ -78,6 +78,7 @@ help.traceroute, "Ctrl+T или F4 = Traceroute", "" help.node_info, "F5 = Полная информация об узле", "" help.archive_chat, "Ctrl+D = Архив чата / удалить узел", "" help.favorite, "Ctrl+F = Избранное", "" +help.bot_responder, "Ctrl+B = Вкл/выкл автоответчик", "" help.ignore, "Ctrl+G = Игнорировать", "" help.search, "Ctrl+/ или / = Поиск", "" help.help, "Ctrl+K = Справка", "" @@ -90,6 +91,11 @@ confirm.remove_ignored, "Убрать {name} из игнорируемых?", "" confirm.region_unset, "Ваш регион НЕ ЗАДАН. Установить сейчас?", "" dialog.resize_title, "Увеличьте окно", "" dialog.resize_body, "Пожалуйста, увеличьте окно до {rows} строк.", "" +bot.status.enabled, "Включен", "" +bot.status.disabled, "Выключен", "" +bot.dialog.title, "Автоответчик", "" +bot.dialog.body, "Автоответчик теперь {status}.", "" +bot.status.message, "Автоответчик теперь {status}.", "" bot.catch_words, "ping; пинг", "Слова для активации бота, разделенные точкой с запятой." bot.response.word, "Понг!", "Ответное слово бота (предпочтительное написание)." diff --git a/contact/ui/contact_ui.py b/contact/ui/contact_ui.py index e30a464..1524c51 100644 --- a/contact/ui/contact_ui.py +++ b/contact/ui/contact_ui.py @@ -937,14 +937,14 @@ def handle_ctrl_k(stdscr: curses.window) -> None: def handle_ctrl_b(stdscr: curses.window) -> None: """Handle Ctrl + B key events to toggle automatic bot responses.""" ui_state.bot_mode_enabled = not ui_state.bot_mode_enabled - status = t("ui.status.enabled", default="Enabled") if ui_state.bot_mode_enabled else t( - "ui.status.disabled", default="Disabled" + status = t("ui.bot.status.enabled", default="Enabled") if ui_state.bot_mode_enabled else t( + "ui.bot.status.disabled", default="Disabled" ) curses.curs_set(0) contact.ui.dialog.dialog( - t("ui.dialog.bot_responder_title", default="Bot Responder"), - t("ui.dialog.bot_responder_body", default="Bot responder is now {status}.", status=status), + t("ui.bot.dialog.title", default="Bot Responder"), + t("ui.bot.dialog.body", default="Bot responder is now {status}.", status=status), ) if ui_state.channel_list: @@ -952,7 +952,7 @@ def handle_ctrl_b(stdscr: curses.window) -> None: add_new_message( channel_id, f"{config.message_prefix} Info: ", - t("ui.status.bot_mode", default="Bot responder is now {status}.", status=status.lower()), + t("ui.bot.status.message", default="Bot responder is now {status}.", status=status.lower()), ) draw_messages_window(True) diff --git a/contact/ui/dialog.py b/contact/ui/dialog.py index 0a0e154..0122e3a 100644 --- a/contact/ui/dialog.py +++ b/contact/ui/dialog.py @@ -2,7 +2,7 @@ import curses from contact.utilities.i18n import t_text from contact.ui.colors import get_color -from contact.ui.nav_utils import draw_main_arrows +from contact.ui.nav_utils import draw_main_arrows, slice_to_width, text_width from contact.utilities.singleton import menu_state, ui_state @@ -19,10 +19,10 @@ def dialog(title: str, message: str) -> None: # Parse message into lines and calculate dimensions message_lines = message.splitlines() or [""] - max_line_length = max(len(l) for l in message_lines) + max_line_length = max(text_width(l) for l in message_lines) # Desired size - dialog_width = max(len(title) + 4, max_line_length + 4) + dialog_width = max(text_width(title) + 4, max_line_length + 4) desired_height = len(message_lines) + 4 # Clamp dialog size to the screen (leave a 1-cell margin if possible) @@ -61,7 +61,7 @@ def dialog(title: str, message: str) -> None: # Title try: - win.addstr(0, 2, title[: max(0, dialog_width - 4)], get_color("settings_default")) + win.addstr(0, 2, slice_to_width(title, max(0, dialog_width - 4)), get_color("settings_default")) except curses.error: pass @@ -80,9 +80,8 @@ def dialog(title: str, message: str) -> None: if idx >= len(message_lines): break line = message_lines[idx] - # Hard-trim lines that don't fit - trimmed = line[: max(0, dialog_width - 6)] - msg_x = max(0, ((dialog_width - 2) - len(trimmed)) // 2) + trimmed = slice_to_width(line, max(0, dialog_width - 4)) + msg_x = max(0, ((dialog_width - 2) - text_width(trimmed)) // 2) try: msg_win.addstr(1 + i, msg_x, trimmed, get_color("settings_default")) except curses.error: diff --git a/tests/test_dialog.py b/tests/test_dialog.py new file mode 100644 index 0000000..26e39b4 --- /dev/null +++ b/tests/test_dialog.py @@ -0,0 +1,88 @@ +import unittest +from unittest import mock + +from contact.ui import dialog as dialog_module +from contact.utilities.singleton import menu_state, ui_state + + +class _FakeWindow: + def __init__(self, height: int, width: int) -> None: + self.height = height + self.width = width + self.children = [] + self.added_strings = [] + self._getch_values = [10] + + def erase(self) -> None: + return None + + def bkgd(self, *_args) -> None: + return None + + def attrset(self, *_args) -> None: + return None + + def border(self, *_args) -> None: + return None + + def addstr(self, y: int, x: int, text: str, *_args) -> None: + self.added_strings.append((y, x, text)) + + def derwin(self, height: int, width: int, _y: int, _x: int): + child = _FakeWindow(height, width) + self.children.append(child) + return child + + def noutrefresh(self) -> None: + return None + + def keypad(self, *_args) -> None: + return None + + def timeout(self, *_args) -> None: + return None + + def getch(self) -> int: + return self._getch_values.pop(0) if self._getch_values else -1 + + def refresh(self) -> None: + return None + + def getmaxyx(self): + return (self.height, self.width) + + +class DialogTests(unittest.TestCase): + def setUp(self) -> None: + self.previous_window = ui_state.current_window + self.previous_start_index = list(ui_state.start_index) + self.previous_need_redraw = menu_state.need_redraw + + def tearDown(self) -> None: + ui_state.current_window = self.previous_window + ui_state.start_index = self.previous_start_index + menu_state.need_redraw = self.previous_need_redraw + + def test_dialog_renders_full_message_when_width_is_sufficient(self) -> None: + root = _FakeWindow(5, 33) + ui_state.current_window = 0 + ui_state.start_index = [0, 0, 0] + menu_state.need_redraw = False + + with mock.patch.object(dialog_module, "t_text", side_effect=lambda text: text): + with mock.patch.object(dialog_module, "get_color", return_value=0): + with mock.patch.object(dialog_module, "draw_main_arrows"): + with mock.patch.object(dialog_module.curses, "update_lines_cols"): + with mock.patch.object(dialog_module.curses, "doupdate"): + with mock.patch.object(dialog_module.curses, "LINES", 24, create=True): + with mock.patch.object(dialog_module.curses, "COLS", 80, create=True): + with mock.patch.object(dialog_module.curses, "newwin", return_value=root): + dialog_module.dialog("Bot Responder", "Bot responder is now Enabled.") + + self.assertTrue(root.children) + message_text = [text for _y, _x, text in root.children[0].added_strings] + self.assertIn("Bot responder is now Enabled.", message_text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_i18n.py b/tests/test_i18n.py index 60bcfc9..5f81885 100644 --- a/tests/test_i18n.py +++ b/tests/test_i18n.py @@ -5,6 +5,7 @@ from unittest import mock import contact.ui.default_config as config from contact.utilities import i18n +from contact.utilities.ini_utils import parse_ini_file from tests.test_support import restore_config, snapshot_config @@ -55,3 +56,19 @@ class I18nTests(unittest.TestCase): config.language = "ru" self.assertEqual(i18n.t("missing", default="fallback"), "fallback") self.assertEqual(parse_ini_file.call_count, 2) + + def test_bot_ui_translation_keys_exist_in_all_locales(self) -> None: + required_keys = { + "ui.help.bot_responder", + "ui.bot.status.enabled", + "ui.bot.status.disabled", + "ui.bot.dialog.title", + "ui.bot.dialog.body", + "ui.bot.status.message", + "ui.bot.catch_words", + "ui.bot.response.word", + } + + for language in config.get_localisation_options(): + field_mapping, _ = parse_ini_file(config.get_localisation_file(language)) + self.assertTrue(required_keys.issubset(field_mapping), msg=f"Missing bot translation keys in {language}")