#!/usr/bin/env python3 """ test_context_menus.py — Unit and integration tests for Right-Click function(s) and Context Action Menus in NetVM/Muse TUI: 1. Right-click on Chat List (sidebar sidechats/threads) opens `chat_context` menu. 2. Right-click on Fleet Agents panel opens `agent_context` menu. 3. Right-click on Transcript message opens `message_context` menu. 4. Chat Context Menu actions (Open, Pin/Unpin, Mark as Read, Copy ID, Copy URL, Reply, Close). 5. Fleet Agent Context Menu actions (Open, Sync, Approvals, Mark All Read, Tmux, Work Order). 6. Message Context Menu actions (Reply, Copy Text, Pin to Prompts, Copy ID). 7. Keyboard navigation (j/k, 1-8, Enter) and mouse click selection inside context menus. 8. Normal mode hotkeys ('x' / 'c') and slash commands (/context chat, /context agent). """ import curses import importlib.util import os import sys import unittest from pathlib import Path from unittest.mock import MagicMock, patch REPO_ROOT = Path(__file__).resolve().parent.parent MUSE_TUI_PATH = REPO_ROOT / "bin" / "muse-tui.py" spec = importlib.util.spec_from_file_location("muse_tui", MUSE_TUI_PATH) muse_tui = importlib.util.module_from_spec(spec) sys.modules["muse_tui"] = muse_tui spec.loader.exec_module(muse_tui) MuseTUI = muse_tui.MuseTUI class TestContextMenus(unittest.TestCase): def setUp(self): self.mock_stdscr = MagicMock() self.mock_stdscr.getmaxyx.return_value = (30, 100) self.tui = MuseTUI(self.mock_stdscr, initial_mode="muse", initial_node="muse") self.tui.safe_addstr = MagicMock() # Seed mock threads self.threads = [ {"session_id": "sess-main-1234", "title": "Main Chat", "is_main": True, "pinned": False}, {"session_id": "sess-side-5678", "title": "Audit Sidechat", "is_main": False, "pinned": True}, {"session_id": "sess-side-9999", "title": "Dev Sidechat", "is_main": False, "pinned": False}, ] with self.tui.data.lock: self.tui.data.pinned_threads["muse"] = set() self.tui.data.threads_cache["muse"] = list(self.threads) self.tui.data.active_thread_id = "sess-main-1234" self.tui.data.active_thread_title = "Main Chat" self.tui.data.history_cache[("muse", "sess-main-1234")] = [ {"role": "user", "text": "Hello muse", "seq": 1, "message_id": "mid-user-1"}, {"role": "assistant", "text": "System ready", "seq": 2, "message_id": "mid-agent-2"}, ] self.tui.data.history_cache[("muse", "sess-side-5678")] = [ {"role": "user", "text": "Review log", "seq": 3, "message_id": "mid-user-3"}, ] def test_right_click_on_chat_list_opens_chat_context(self): """Right-clicking a thread row in sidebar opens chat_context modal.""" # Screen layout: content_y = 1, visible_agents = 6, divider_y = 8, threads_hdr_y = 9, list_y = 10 # Right click on thread index 1 (sess-side-5678) at my = 11, mx = 10 bstate = curses.BUTTON3_CLICKED self.tui._handle_mouse(mx=10, my=11, bstate=bstate) self.assertEqual(self.tui.modal, "chat_context") self.assertIsNotNone(self.tui.context_chat) self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678") self.assertEqual(self.tui.context_chat["thread"]["title"], "Audit Sidechat") def test_right_click_on_fleet_agent_opens_agent_context(self): """Right-clicking an agent row in sidebar opens agent_context modal.""" # Row content_y + 1 = 2 is first agent (index 0 = 'muse') # Row content_y + 2 = 3 is second agent bstate = getattr(curses, "BUTTON3_PRESSED", 0x800) with self.tui.data.lock: second_agent = self.tui.data.nodes[1] if len(self.tui.data.nodes) > 1 else "muse" self.tui._handle_mouse(mx=10, my=3, bstate=bstate) self.assertEqual(self.tui.modal, "agent_context") self.assertIsNotNone(self.tui.context_agent) self.assertEqual(self.tui.context_agent["node"], second_agent) def test_right_click_on_transcript_opens_message_context(self): """Right-clicking a message in transcript opens message_context modal.""" # Populate rendered lines in transcript self.tui._render_transcript(1, 26, 25, 74) # Click on transcript area mx = 50, my = 5 bstate = curses.BUTTON3_CLICKED self.tui._handle_mouse(mx=50, my=5, bstate=bstate) self.assertEqual(self.tui.modal, "message_context") self.assertIsNotNone(self.tui.context_message) self.assertIn("text", self.tui.context_message) def test_chat_context_pin_action(self): """Executing 'pin' action in chat_context toggles pin state.""" t = {"session_id": "sess-side-9999", "title": "Dev Sidechat", "pinned": False} self.tui.context_chat = {"node": "muse", "thread": t, "index": 2} self.tui.modal = "chat_context" # Press 'p' self.tui._handle_modal_key(ord('p')) self.assertIsNone(self.tui.modal) self.assertIn("📌 Pinned chat", self.tui.toast_msg) self.assertIn("sess-side-9999", self.tui.data.pinned_threads.get("muse", set())) def test_chat_context_mark_read_action(self): """Executing 'read' action marks thread as read.""" t = {"session_id": "sess-side-5678", "title": "Audit Sidechat"} self.tui.context_chat = {"node": "muse", "thread": t, "index": 1} self.tui.modal = "chat_context" self.tui._handle_modal_key(ord('m')) self.assertIsNone(self.tui.modal) self.assertIn("Marked 'Audit Sidechat' as read", self.tui.toast_msg) self.assertEqual(self.tui.data.get_unread_count("muse", "sess-side-5678"), 0) def test_chat_context_copy_id_action(self): """Executing 'copy_id' action copies session ID to clipboard.""" t = {"session_id": "sess-side-5678", "title": "Audit Sidechat"} self.tui.context_chat = {"node": "muse", "thread": t, "index": 1} self.tui.modal = "chat_context" mod = sys.modules.get(MuseTUI.__module__, muse_tui) with patch.object(mod, "copy_to_clipboard") as mock_cp1, \ patch.object(muse_tui, "copy_to_clipboard") as mock_cp2: self.tui._handle_modal_key(ord('y')) self.assertTrue(mock_cp1.called or mock_cp2.called) self.assertIn("Copied session ID", self.tui.toast_msg) def test_chat_context_reply_action(self): """Executing 'reply' action switches to chat and quotes latest message.""" t = {"session_id": "sess-main-1234", "title": "Main Chat"} self.tui.context_chat = {"node": "muse", "thread": t, "index": 0} self.tui.modal = "chat_context" self.tui._handle_modal_key(ord('r')) self.assertIsNone(self.tui.modal) self.assertIsNotNone(self.tui.reply_target) self.assertEqual(self.tui.reply_target["role"], "assistant") self.assertIn("System ready", self.tui.reply_target["text"]) def test_agent_context_actions(self): """Agent context menu hotkeys execute appropriate agent actions.""" self.tui.context_agent = {"node": "muse"} self.tui.modal = "agent_context" # Mark all chats on agent as read ('m') self.tui._handle_modal_key(ord('m')) self.assertIsNone(self.tui.modal) self.assertIn("Marked all chats on MUSE as read", self.tui.toast_msg) # Open chat with agent ('1') self.tui.context_agent = {"node": "muse"} self.tui.modal = "agent_context" self.tui._handle_modal_key(ord('1')) self.assertIsNone(self.tui.modal) self.assertIn("Switched to agent", self.tui.toast_msg) def test_normal_mode_x_opens_chat_context(self): """Pressing 'x' in normal mode when focused on sidebar opens chat_context.""" self.tui.editor_mode = "NORMAL" self.tui.focus_pane = "sidebar" self.tui.thread_sel_idx = 1 handled = self.tui._handle_key(ord('x')) self.assertTrue(handled) self.assertEqual(self.tui.modal, "chat_context") self.assertEqual(self.tui.context_chat["thread"]["session_id"], "sess-side-5678") def test_slash_command_context(self): """Slash command /context opens chat_context or agent_context.""" self.tui._execute_input_line("/context chat") self.assertEqual(self.tui.modal, "chat_context") self.tui.modal = None self.tui._execute_input_line("/context agent") self.assertEqual(self.tui.modal, "agent_context") if __name__ == "__main__": unittest.main()