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 19 20from unittest.mock import MagicMock 21 22from prompt_toolkit.application import create_app_session 23from prompt_toolkit.output import ColorDepth 24 25# inclusive-language: ignore 26from prompt_toolkit.output import DummyOutput as FakeOutput 27 28from pw_console.command_runner import CommandRunnerItem 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 maxDiff = None 86 87 def test_flatten_menu_items(self) -> None: 88 with create_app_session(output=FakeOutput()): 89 console_app = _create_console_app(log_pane_count=2) 90 flattened_menu_items = [ 91 item.title 92 for item in console_app.command_runner.load_menu_items() 93 ] 94 95 # Check some common menu items exist. 96 self.assertIn('[File] > Open Logger', flattened_menu_items) 97 self.assertIn( 98 '[File] > Themes > UI Themes > High Contrast', 99 flattened_menu_items, 100 ) 101 self.assertIn('[Help] > User Guide', flattened_menu_items) 102 self.assertIn('[Help] > Keyboard Shortcuts', flattened_menu_items) 103 # Check for log windows 104 self.assertRegex( 105 '\n'.join(flattened_menu_items), 106 re.compile( 107 r'^\[Windows\] > .* LogPane-[0-9]+ > .*$', re.MULTILINE 108 ), 109 ) 110 111 def test_filter_and_highlight_matches(self) -> None: 112 """Check filtering matches and highlighting works correctly.""" 113 with create_app_session(output=FakeOutput()): 114 console_app = _create_console_app(log_pane_count=2) 115 command_runner = console_app.command_runner 116 117 command_runner.filter_completions = MagicMock( 118 wraps=command_runner.filter_completions 119 ) 120 command_runner.width = 20 121 122 # Define custom completion items 123 def empty_handler() -> None: 124 return None 125 126 def get_completions() -> list[CommandRunnerItem]: 127 return [ 128 CommandRunnerItem('[File] > Open Logger', empty_handler), 129 CommandRunnerItem( 130 '[Windows] > 1: Host Logs > Show/Hide', empty_handler 131 ), 132 CommandRunnerItem( 133 '[Windows] > 2: Device Logs > Show/Hide', empty_handler 134 ), 135 CommandRunnerItem('[Help] > User Guide', empty_handler), 136 ] 137 138 command_runner.filter_completions.assert_not_called() 139 command_runner.set_completions( 140 window_title='Test Completions', 141 load_completions=get_completions, 142 ) 143 command_runner.filter_completions.assert_called_once() 144 command_runner.filter_completions.reset_mock() 145 146 # Input field should be empty 147 self.assertEqual(command_runner.input_field.buffer.text, '') 148 # Flatten resulting formatted text 149 result_items = join_adjacent_style_tuples( 150 flatten_formatted_text_tuples( 151 command_runner.completion_fragments 152 ) 153 ) 154 155 # index 0: the selected line 156 # index 1: the rest of the completions with line breaks 157 self.assertEqual(len(result_items), 2) 158 first_item_style = result_items[0][0] 159 first_item_text = result_items[0][1] 160 second_item_text = result_items[1][1] 161 # Check expected number of lines are present 162 self.assertEqual(len(first_item_text.splitlines()), 1) 163 self.assertEqual(len(second_item_text.splitlines()), 3) 164 # First line is highlighted as a selected item 165 self.assertEqual( 166 first_item_style, 'class:command-runner-selected-item' 167 ) 168 self.assertIn('[File] > Open Logger', first_item_text) 169 170 # Type: file open 171 command_runner.input_field.buffer.text = 'file open' 172 self.assertEqual( 173 command_runner.input_field.buffer.text, 'file open' 174 ) 175 # Run the filter 176 command_runner.filter_completions() 177 # Flatten resulting formatted text 178 result_items = join_adjacent_style_tuples( 179 flatten_formatted_text_tuples( 180 command_runner.completion_fragments 181 ) 182 ) 183 # Check file and open are highlighted 184 self.assertEqual( 185 result_items[:4], 186 [ 187 ('class:command-runner-selected-item', '['), 188 ( 189 'class:command-runner-selected-item ' 190 'class:command-runner-fuzzy-highlight-0 ', 191 'File', 192 ), 193 ('class:command-runner-selected-item', '] > '), 194 ( 195 'class:command-runner-selected-item ' 196 'class:command-runner-fuzzy-highlight-1 ', 197 'Open', 198 ), 199 ], 200 ) 201 202 # Type: open file 203 command_runner.input_field.buffer.text = 'open file' 204 # Run the filter 205 command_runner.filter_completions() 206 result_items = join_adjacent_style_tuples( 207 flatten_formatted_text_tuples( 208 command_runner.completion_fragments 209 ) 210 ) 211 # Check file and open are highlighted, the fuzzy-highlight class 212 # should be swapped. 213 self.assertEqual( 214 result_items[:4], 215 [ 216 ('class:command-runner-selected-item', '['), 217 ( 218 'class:command-runner-selected-item ' 219 'class:command-runner-fuzzy-highlight-1 ', 220 'File', 221 ), 222 ('class:command-runner-selected-item', '] > '), 223 ( 224 'class:command-runner-selected-item ' 225 'class:command-runner-fuzzy-highlight-0 ', 226 'Open', 227 ), 228 ], 229 ) 230 231 # Clear input 232 command_runner._reset_selected_item() # pylint: disable=protected-access 233 command_runner.filter_completions() 234 result_items = join_adjacent_style_tuples( 235 flatten_formatted_text_tuples( 236 command_runner.completion_fragments 237 ) 238 ) 239 self.assertEqual(len(first_item_text.splitlines()), 1) 240 self.assertEqual(len(second_item_text.splitlines()), 3) 241 242 # Press down (select the next item) 243 command_runner._next_item() # pylint: disable=protected-access 244 # Filter and check results 245 command_runner.filter_completions() 246 result_items = join_adjacent_style_tuples( 247 flatten_formatted_text_tuples( 248 command_runner.completion_fragments 249 ) 250 ) 251 self.assertEqual(len(result_items), 3) 252 # First line - not selected 253 self.assertEqual(result_items[0], ('', '[File] > Open Logger\n')) 254 # Second line - is selected 255 self.assertEqual( 256 result_items[1], 257 ( 258 'class:command-runner-selected-item', 259 '[Windows] > 1: Host Logs > Show/Hide\n', 260 ), 261 ) 262 # Third and fourth lines separated by \n - not selected 263 self.assertEqual( 264 result_items[2], 265 ( 266 '', 267 '[Windows] > 2: Device Logs > Show/Hide\n' 268 '[Help] > User Guide', 269 ), 270 ) 271 272 def test_run_action(self) -> None: 273 """Check running an action works correctly.""" 274 with create_app_session(output=FakeOutput()): 275 console_app = _create_console_app(log_pane_count=2) 276 command_runner = console_app.command_runner 277 self.assertEqual( 278 window_pane_titles(console_app.window_manager), 279 [ 280 # Split 1 281 [ 282 'LogPane-1 - test_log1', 283 'LogPane-0 - test_log0', 284 'Python Repl - ', 285 ], 286 ], 287 ) 288 command_runner.open_dialog() 289 # Set LogPane-1 as the focused window pane 290 target_list_and_pane(console_app.window_manager, 0, 0) 291 292 command_runner.input_field.buffer.text = 'move right' 293 294 # pylint: disable=protected-access 295 command_runner._make_regexes = MagicMock( 296 wraps=command_runner._make_regexes 297 ) 298 # pylint: enable=protected-access 299 command_runner.filter_completions() 300 # Filter should only be re-run if input text changed 301 command_runner.filter_completions() 302 command_runner._make_regexes.assert_called_once() # pylint: disable=protected-access 303 304 self.assertIn( 305 '[View] > Move Window Right', command_runner.selected_item_title 306 ) 307 # Run the Move Window Right action 308 command_runner._run_selected_item() # pylint: disable=protected-access 309 # Dialog should be closed 310 self.assertFalse(command_runner.show_dialog) 311 # LogPane-1 should be moved to the right in it's own split 312 self.assertEqual( 313 window_pane_titles(console_app.window_manager), 314 [ 315 # Split 1 316 [ 317 'LogPane-0 - test_log0', 318 'Python Repl - ', 319 ], 320 # Split 2 321 [ 322 'LogPane-1 - test_log1', 323 ], 324 ], 325 ) 326 327 328if __name__ == '__main__': 329 unittest.main() 330