1# Copyright 2022 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""Tests for pw_console.command_runner dialog.""" 15 16import logging 17import re 18import unittest 19from typing import Callable, List, Tuple 20 21from unittest.mock import MagicMock 22 23from prompt_toolkit.application import create_app_session 24from prompt_toolkit.output import ColorDepth 25 26# inclusive-language: ignore 27from prompt_toolkit.output import DummyOutput as FakeOutput 28 29from pw_console.console_app import ConsoleApp 30from pw_console.console_prefs import ConsolePrefs 31from pw_console.text_formatting import ( 32 flatten_formatted_text_tuples, 33 join_adjacent_style_tuples, 34) 35 36 37def _create_console_app(log_pane_count=2): 38 prefs = ConsolePrefs( 39 project_file=False, project_user_file=False, user_file=False 40 ) 41 prefs.set_code_theme('default') 42 console_app = ConsoleApp(color_depth=ColorDepth.DEPTH_8_BIT, prefs=prefs) 43 44 console_app.prefs.reset_config() 45 46 # Setup log panes 47 loggers = {} 48 for i in range(log_pane_count): 49 loggers['LogPane-{}'.format(i)] = [ 50 logging.getLogger('test_log{}'.format(i)) 51 ] 52 for window_title, logger_instances in loggers.items(): 53 console_app.add_log_handler(window_title, logger_instances) 54 55 return console_app 56 57 58def window_pane_titles(window_manager): 59 return [ 60 [ 61 pane.pane_title() + ' - ' + pane.pane_subtitle() 62 for pane in window_list.active_panes 63 ] 64 for window_list in window_manager.window_lists 65 ] 66 67 68def target_list_and_pane(window_manager, list_index, pane_index): 69 # pylint: disable=protected-access 70 # Bypass prompt_toolkit has_focus() 71 pane = window_manager.window_lists[list_index].active_panes[pane_index] 72 # If the pane is in focus it will be visible. 73 pane.show_pane = True 74 window_manager._get_active_window_list_and_pane = MagicMock( # type: ignore 75 return_value=( 76 window_manager.window_lists[list_index], 77 window_manager.window_lists[list_index].active_panes[pane_index], 78 ) 79 ) 80 81 82class TestCommandRunner(unittest.TestCase): 83 """Tests for CommandRunner.""" 84 85 def setUp(self): 86 self.maxDiff = None # pylint: disable=invalid-name 87 88 def test_flatten_menu_items(self) -> None: 89 with create_app_session(output=FakeOutput()): 90 console_app = _create_console_app(log_pane_count=2) 91 flattened_menu_items = [ 92 text 93 # pylint: disable=line-too-long 94 for text, handler in console_app.command_runner.load_menu_items() 95 # pylint: enable=line-too-long 96 ] 97 98 # Check some common menu items exist. 99 self.assertIn('[File] > Open Logger', flattened_menu_items) 100 self.assertIn( 101 '[File] > Themes > UI Themes > High Contrast', 102 flattened_menu_items, 103 ) 104 self.assertIn('[Help] > User Guide', flattened_menu_items) 105 self.assertIn('[Help] > Keyboard Shortcuts', flattened_menu_items) 106 # Check for log windows 107 self.assertRegex( 108 '\n'.join(flattened_menu_items), 109 re.compile( 110 r'^\[Windows\] > .* LogPane-[0-9]+ > .*$', re.MULTILINE 111 ), 112 ) 113 114 def test_filter_and_highlight_matches(self) -> None: 115 """Check filtering matches and highlighting works correctly.""" 116 with create_app_session(output=FakeOutput()): 117 console_app = _create_console_app(log_pane_count=2) 118 command_runner = console_app.command_runner 119 120 command_runner.filter_completions = MagicMock( 121 wraps=command_runner.filter_completions 122 ) 123 command_runner.width = 20 124 125 # Define custom completion items 126 def empty_handler() -> None: 127 return None 128 129 def get_completions() -> List[Tuple[str, Callable]]: 130 return [ 131 ('[File] > Open Logger', empty_handler), 132 ('[Windows] > 1: Host Logs > Show/Hide', empty_handler), 133 ('[Windows] > 2: Device Logs > Show/Hide', empty_handler), 134 ('[Help] > User Guide', empty_handler), 135 ] 136 137 command_runner.filter_completions.assert_not_called() 138 command_runner.set_completions( 139 window_title='Test Completions', 140 load_completions=get_completions, 141 ) 142 command_runner.filter_completions.assert_called_once() 143 command_runner.filter_completions.reset_mock() 144 145 # Input field should be empty 146 self.assertEqual(command_runner.input_field.buffer.text, '') 147 # Flatten resulting formatted text 148 result_items = join_adjacent_style_tuples( 149 flatten_formatted_text_tuples( 150 command_runner.completion_fragments 151 ) 152 ) 153 154 # index 0: the selected line 155 # index 1: the rest of the completions with line breaks 156 self.assertEqual(len(result_items), 2) 157 first_item_style = result_items[0][0] 158 first_item_text = result_items[0][1] 159 second_item_text = result_items[1][1] 160 # Check expected number of lines are present 161 self.assertEqual(len(first_item_text.splitlines()), 1) 162 self.assertEqual(len(second_item_text.splitlines()), 3) 163 # First line is highlighted as a selected item 164 self.assertEqual( 165 first_item_style, 'class:command-runner-selected-item' 166 ) 167 self.assertIn('[File] > Open Logger', first_item_text) 168 169 # Type: file open 170 command_runner.input_field.buffer.text = 'file open' 171 self.assertEqual( 172 command_runner.input_field.buffer.text, 'file open' 173 ) 174 # Run the filter 175 command_runner.filter_completions() 176 # Flatten resulting formatted text 177 result_items = join_adjacent_style_tuples( 178 flatten_formatted_text_tuples( 179 command_runner.completion_fragments 180 ) 181 ) 182 # Check file and open are highlighted 183 self.assertEqual( 184 result_items[:4], 185 [ 186 ('class:command-runner-selected-item', '['), 187 ( 188 'class:command-runner-selected-item ' 189 'class:command-runner-fuzzy-highlight-0 ', 190 'File', 191 ), 192 ('class:command-runner-selected-item', '] > '), 193 ( 194 'class:command-runner-selected-item ' 195 'class:command-runner-fuzzy-highlight-1 ', 196 'Open', 197 ), 198 ], 199 ) 200 201 # Type: open file 202 command_runner.input_field.buffer.text = 'open file' 203 # Run the filter 204 command_runner.filter_completions() 205 result_items = join_adjacent_style_tuples( 206 flatten_formatted_text_tuples( 207 command_runner.completion_fragments 208 ) 209 ) 210 # Check file and open are highlighted, the fuzzy-highlight class 211 # should be swapped. 212 self.assertEqual( 213 result_items[:4], 214 [ 215 ('class:command-runner-selected-item', '['), 216 ( 217 'class:command-runner-selected-item ' 218 'class:command-runner-fuzzy-highlight-1 ', 219 'File', 220 ), 221 ('class:command-runner-selected-item', '] > '), 222 ( 223 'class:command-runner-selected-item ' 224 'class:command-runner-fuzzy-highlight-0 ', 225 'Open', 226 ), 227 ], 228 ) 229 230 # Clear input 231 command_runner._reset_selected_item() # pylint: disable=protected-access 232 command_runner.filter_completions() 233 result_items = join_adjacent_style_tuples( 234 flatten_formatted_text_tuples( 235 command_runner.completion_fragments 236 ) 237 ) 238 self.assertEqual(len(first_item_text.splitlines()), 1) 239 self.assertEqual(len(second_item_text.splitlines()), 3) 240 241 # Press down (select the next item) 242 command_runner._next_item() # pylint: disable=protected-access 243 # Filter and check results 244 command_runner.filter_completions() 245 result_items = join_adjacent_style_tuples( 246 flatten_formatted_text_tuples( 247 command_runner.completion_fragments 248 ) 249 ) 250 self.assertEqual(len(result_items), 3) 251 # First line - not selected 252 self.assertEqual(result_items[0], ('', '[File] > Open Logger\n')) 253 # Second line - is selected 254 self.assertEqual( 255 result_items[1], 256 ( 257 'class:command-runner-selected-item', 258 '[Windows] > 1: Host Logs > Show/Hide\n', 259 ), 260 ) 261 # Third and fourth lines separated by \n - not selected 262 self.assertEqual( 263 result_items[2], 264 ( 265 '', 266 '[Windows] > 2: Device Logs > Show/Hide\n' 267 '[Help] > User Guide', 268 ), 269 ) 270 271 def test_run_action(self) -> None: 272 """Check running an action works correctly.""" 273 with create_app_session(output=FakeOutput()): 274 console_app = _create_console_app(log_pane_count=2) 275 command_runner = console_app.command_runner 276 self.assertEqual( 277 window_pane_titles(console_app.window_manager), 278 [ 279 # Split 1 280 [ 281 'LogPane-1 - test_log1', 282 'LogPane-0 - test_log0', 283 'Python Repl - ', 284 ], 285 ], 286 ) 287 command_runner.open_dialog() 288 # Set LogPane-1 as the focused window pane 289 target_list_and_pane(console_app.window_manager, 0, 0) 290 291 command_runner.input_field.buffer.text = 'move right' 292 293 # pylint: disable=protected-access 294 command_runner._make_regexes = MagicMock( 295 wraps=command_runner._make_regexes 296 ) 297 # pylint: enable=protected-access 298 command_runner.filter_completions() 299 # Filter should only be re-run if input text changed 300 command_runner.filter_completions() 301 command_runner._make_regexes.assert_called_once() # pylint: disable=protected-access 302 303 self.assertIn( 304 '[View] > Move Window Right', command_runner.selected_item_text 305 ) 306 # Run the Move Window Right action 307 command_runner._run_selected_item() # pylint: disable=protected-access 308 # Dialog should be closed 309 self.assertFalse(command_runner.show_dialog) 310 # LogPane-1 should be moved to the right in it's own split 311 self.assertEqual( 312 window_pane_titles(console_app.window_manager), 313 [ 314 # Split 1 315 [ 316 'LogPane-0 - test_log0', 317 'Python Repl - ', 318 ], 319 # Split 2 320 [ 321 'LogPane-1 - test_log1', 322 ], 323 ], 324 ) 325 326 327if __name__ == '__main__': 328 unittest.main() 329