pw_console: Improve pw console --test-mode

- Move test functions into their own source
- Override the window layout for test-mode. By default 3 log panes
  are shown, 2 with filters added.
- Remove unused byte_size from LogStore class for PyPy compatibility

Change-Id: Ib6fa4f67f9d74a83df05aebc5bd58a3bf53036cb
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/108840
Commit-Queue: Anthony DiGirolamo <tonymd@google.com>
Reviewed-by: Armando Montanez <amontanez@google.com>
diff --git a/pw_console/py/BUILD.gn b/pw_console/py/BUILD.gn
index 09f5c2d..1275485 100644
--- a/pw_console/py/BUILD.gn
+++ b/pw_console/py/BUILD.gn
@@ -63,6 +63,7 @@
     "pw_console/search_toolbar.py",
     "pw_console/style.py",
     "pw_console/templates/__init__.py",
+    "pw_console/test_mode.py",
     "pw_console/text_formatting.py",
     "pw_console/toml_config_loader_mixin.py",
     "pw_console/widgets/__init__.py",
diff --git a/pw_console/py/pw_console/__main__.py b/pw_console/py/pw_console/__main__.py
index 97c4e77..58a3d84 100644
--- a/pw_console/py/pw_console/__main__.py
+++ b/pw_console/py/pw_console/__main__.py
@@ -18,12 +18,14 @@
 import logging
 from pathlib import Path
 import sys
+from typing import Optional, Dict
 
 import pw_cli.log
 import pw_cli.argument_types
 
 import pw_console
 import pw_console.python_logging
+import pw_console.test_mode
 from pw_console.log_store import LogStore
 from pw_console.plugins.calc_pane import CalcPane
 from pw_console.plugins.clock_pane import ClockPane
@@ -95,11 +97,11 @@
         _ROOT_LOG.debug('pw_console test-mode starting...')
 
         fake_logger = logging.getLogger(
-            pw_console.console_app.FAKE_DEVICE_LOGGER_NAME)
+            pw_console.test_mode.FAKE_DEVICE_LOGGER_NAME)
         default_loggers = {
             # Don't include pw_console package logs (_LOG) in the log pane UI.
             # Add the fake logger for test_mode.
-            'Fake Device Logs': [fake_logger],
+            'Fake Device': [fake_logger],
             'PwConsole Debug': [logging.getLogger('pw_console')],
             'All Logs': root_log_store,
         }
@@ -129,9 +131,13 @@
         config_file_path=args.config_file,
     )
 
-    # Add example plugins used to validate behavior in the Pigweed Console
-    # manual test procedure: https://pigweed.dev/pw_console/testing.html
+    overriden_window_config: Optional[Dict] = None
+    # Add example plugins and log panes used to validate behavior in the Pigweed
+    # Console manual test procedure: https://pigweed.dev/pw_console/testing.html
     if args.test_mode:
+        fake_logger.propagate = False
+        console.setup_python_logging(loggers_with_no_propagation=[fake_logger])
+
         _ROOT_LOG.debug('pw_console.PwConsoleEmbed init complete')
         _ROOT_LOG.debug('Adding plugins...')
         console.add_window_plugin(ClockPane())
@@ -140,7 +146,36 @@
             Twenty48Pane(include_resize_handle=False), left=4)
         _ROOT_LOG.debug('Starting prompt_toolkit full-screen application...')
 
-    console.embed()
+        overriden_window_config = {
+            'Split 1 stacked': {
+                'Fake Device': None,
+                'Fake Keys': {
+                    'duplicate_of': 'Fake Device',
+                    'filters': {
+                        'keys': {
+                            'regex': '[^ ]+'
+                        },
+                    },
+                },
+                'Fake USB': {
+                    'duplicate_of': 'Fake Device',
+                    'filters': {
+                        'module': {
+                            'regex': 'USB'
+                        },
+                    },
+                },
+            },
+            'Split 2 tabbed': {
+                'Python Repl': None,
+                'All Logs': None,
+                'PwConsole Debug': None,
+                'Calculator': None,
+                'Clock': None,
+            },
+        }
+
+    console.embed(override_window_config=overriden_window_config)
 
     if args.logfile:
         print(f'Logs saved to: {args.logfile}')
diff --git a/pw_console/py/pw_console/console_app.py b/pw_console/py/pw_console/console_app.py
index a957100..f83a428 100644
--- a/pw_console/py/pw_console/console_app.py
+++ b/pw_console/py/pw_console/console_app.py
@@ -67,18 +67,14 @@
 from pw_console.quit_dialog import QuitDialog
 from pw_console.repl_pane import ReplPane
 import pw_console.style
+from pw_console.test_mode import start_fake_logger
 import pw_console.widgets.checkbox
 from pw_console.widgets import FloatingWindowPane
 import pw_console.widgets.mouse_handlers
 from pw_console.window_manager import WindowManager
 
 _LOG = logging.getLogger(__package__)
-
-# Fake logger for --test-mode
-FAKE_DEVICE_LOGGER_NAME = 'pw_console_fake_device'
-_FAKE_DEVICE_LOG = logging.getLogger(FAKE_DEVICE_LOGGER_NAME)
-# Don't send fake_device logs to the root Python logger.
-_FAKE_DEVICE_LOG.propagate = False
+_ROOT_LOG = logging.getLogger('')
 
 MAX_FPS = 30
 MIN_REDRAW_INTERVAL = (60.0 / MAX_FPS) / 60.0
@@ -189,6 +185,7 @@
 
         # Event loop for executing user repl code.
         self.user_code_loop = asyncio.new_event_loop()
+        self.test_mode_log_loop = asyncio.new_event_loop()
 
         self.app_title = app_title if app_title else 'Pigweed Console'
 
@@ -875,6 +872,11 @@
                         daemon=True)
         thread.start()
 
+    def _test_mode_log_thread_entry(self):
+        """Entry point for the user code thread."""
+        asyncio.set_event_loop(self.test_mode_log_loop)
+        self.test_mode_log_loop.run_forever()
+
     def _update_help_window(self):
         """Generate the help window text based on active pane keybindings."""
         # Add global mouse bindings to the help text.
@@ -963,7 +965,10 @@
     async def run(self, test_mode=False):
         """Start the prompt_toolkit UI."""
         if test_mode:
-            background_log_task = asyncio.create_task(self.log_forever())
+            background_log_task = start_fake_logger(
+                lines=self.user_guide_window.help_text_area.document.lines,
+                log_thread_entry=self._test_mode_log_thread_entry,
+                log_thread_loop=self.test_mode_log_loop)
 
         # Repl pane has focus by default, if it's hidden switch focus to another
         # visible pane.
@@ -977,44 +982,6 @@
             if test_mode:
                 background_log_task.cancel()
 
-    async def log_forever(self):
-        """Test mode async log generator coroutine that runs forever."""
-        message_count = 0
-        # Sample log line format:
-        # Log message [=         ] # 100
-
-        # Fake module column names.
-        module_names = ['APP', 'RADIO', 'BAT', 'USB', 'CPU']
-        while True:
-            if message_count > 32 or message_count < 2:
-                await asyncio.sleep(1)
-            bar_size = 10
-            position = message_count % bar_size
-            bar_content = " " * (bar_size - position - 1) + "="
-            if position > 0:
-                bar_content = "=".rjust(position) + " " * (bar_size - position)
-            new_log_line = 'Log message [{}] # {}'.format(
-                bar_content, message_count)
-            if message_count % 10 == 0:
-                new_log_line += (
-                    ' Lorem ipsum \033[34m\033[1mdolor sit amet\033[0m'
-                    ', consectetur '
-                    'adipiscing elit.') * 8
-            if message_count % 11 == 0:
-                new_log_line += ' '
-                new_log_line += (
-                    '[PYTHON] START\n'
-                    'In []: import time;\n'
-                    '        def t(s):\n'
-                    '            time.sleep(s)\n'
-                    '            return "t({}) seconds done".format(s)\n\n')
-
-            module_name = module_names[message_count % len(module_names)]
-            _FAKE_DEVICE_LOG.info(new_log_line,
-                                  extra=dict(extra_metadata_fields=dict(
-                                      module=module_name, file='fake_app.cc')))
-            message_count += 1
-
 
 # TODO(tonymd): Remove this alias when not used by downstream projects.
 def embed(
diff --git a/pw_console/py/pw_console/console_prefs.py b/pw_console/py/pw_console/console_prefs.py
index f5621f6..fb31bbf 100644
--- a/pw_console/py/pw_console/console_prefs.py
+++ b/pw_console/py/pw_console/console_prefs.py
@@ -219,9 +219,12 @@
         return self._config.get('window_column_split_method', 'vertical')
 
     @property
-    def windows(self) -> dict:
+    def windows(self) -> Dict:
         return self._config.get('windows', {})
 
+    def set_windows(self, new_config: Dict) -> None:
+        self._config['windows'] = new_config
+
     @property
     def window_column_modes(self) -> List:
         return list(column_type for column_type in self.windows.keys())
@@ -300,11 +303,11 @@
         return decorator
 
     @property
-    def snippets(self) -> dict:
+    def snippets(self) -> Dict:
         return self._config.get('snippets', {})
 
     @property
-    def user_snippets(self) -> dict:
+    def user_snippets(self) -> Dict:
         return self._config.get('user_snippets', {})
 
     def snippet_completions(self) -> List[Tuple[str, str]]:
diff --git a/pw_console/py/pw_console/embed.py b/pw_console/py/pw_console/embed.py
index e6dcddd..c498615 100644
--- a/pw_console/py/pw_console/embed.py
+++ b/pw_console/py/pw_console/embed.py
@@ -281,7 +281,7 @@
         for window_title in window_titles:
             self.hidden_by_default_windows.append(window_title)
 
-    def embed(self) -> None:
+    def embed(self, override_window_config: Optional[Dict] = None) -> None:
         """Start the console."""
 
         # Create the ConsoleApp instance.
@@ -328,6 +328,8 @@
         if self.config_file_path:
             self.console_app.load_clean_config(self.config_file_path)
 
+        if override_window_config:
+            self.console_app.prefs.set_windows(override_window_config)
         self.console_app.apply_window_config()
 
         # Hide the repl pane if it's in the hidden windows list.
diff --git a/pw_console/py/pw_console/log_store.py b/pw_console/py/pw_console/log_store.py
index 33c5d68..b06a75d 100644
--- a/pw_console/py/pw_console/log_store.py
+++ b/pw_console/py/pw_console/log_store.py
@@ -16,7 +16,6 @@
 from __future__ import annotations
 import collections
 import logging
-import sys
 from datetime import datetime
 from typing import Dict, List, Optional, TYPE_CHECKING
 
@@ -91,9 +90,6 @@
         # and end of the iterable.
         self.logs: collections.deque = collections.deque()
 
-        # Estimate of the logs in memory.
-        self.byte_size: int = 0
-
         # Only allow this many log lines in memory.
         self.max_history_size: int = 1000000
 
@@ -146,7 +142,6 @@
     def clear_logs(self):
         """Erase all stored pane lines."""
         self.logs = collections.deque()
-        self.byte_size = 0
         self.channel_counts = {}
         self.channel_formatted_prefix_widths = {}
         self.line_index = 0
@@ -227,12 +222,6 @@
         # Check for bigger column widths.
         self.table.update_metadata_column_widths(self.logs[-1])
 
-        # Update estimated byte_size.
-        self.byte_size += sys.getsizeof(self.logs[-1])
-        # If the total log lines is > max_history_size, delete the oldest line.
-        if self.get_total_count() > self.max_history_size:
-            self.byte_size -= sys.getsizeof(self.logs.popleft())
-
     def emit(self, record) -> None:
         """Process a new log record.
 
diff --git a/pw_console/py/pw_console/plugins/twenty48_pane.py b/pw_console/py/pw_console/plugins/twenty48_pane.py
index 832ce3e..a215d89 100644
--- a/pw_console/py/pw_console/plugins/twenty48_pane.py
+++ b/pw_console/py/pw_console/plugins/twenty48_pane.py
@@ -500,6 +500,9 @@
         )
 
     def get_top_level_menus(self) -> List[MenuItem]:
+        def _toggle_dialog() -> None:
+            self.toggle_dialog()
+
         return [
             MenuItem(
                 '[2048]',
@@ -509,6 +512,7 @@
                              disabled=True),
                     # Menu separator
                     MenuItem('-', None),
+                    MenuItem('Show/Hide 2048 Game', handler=_toggle_dialog),
                     MenuItem('Restart', handler=self.game.reset_game),
                 ],
             ),
diff --git a/pw_console/py/pw_console/test_mode.py b/pw_console/py/pw_console/test_mode.py
new file mode 100644
index 0000000..54b0573
--- /dev/null
+++ b/pw_console/py/pw_console/test_mode.py
@@ -0,0 +1,81 @@
+# Copyright 2022 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""pw_console test mode functions."""
+
+import asyncio
+import time
+import re
+import random
+import logging
+from threading import Thread
+from typing import Dict, List, Tuple
+
+FAKE_DEVICE_LOGGER_NAME = 'pw_console_fake_device'
+
+_ROOT_LOG = logging.getLogger('')
+_FAKE_DEVICE_LOG = logging.getLogger(FAKE_DEVICE_LOGGER_NAME)
+
+
+def start_fake_logger(lines, log_thread_entry, log_thread_loop):
+    fake_log_messages = prepare_fake_logs(lines)
+
+    test_log_thread = Thread(target=log_thread_entry, args=(), daemon=True)
+    test_log_thread.start()
+
+    background_log_task = asyncio.run_coroutine_threadsafe(
+        # This function will be executed in a separate thread.
+        log_forever(fake_log_messages),
+        # Using this asyncio event loop.
+        log_thread_loop)  # type: ignore
+    return background_log_task
+
+
+def prepare_fake_logs(lines) -> List[Tuple[str, Dict]]:
+    fake_logs: List[Tuple[str, Dict]] = []
+    key_regex = re.compile(r':kbd:`(?P<key>[^`]+)`')
+    for line in lines:
+        if not line:
+            continue
+
+        keyboard_key = ''
+        search = key_regex.search(line)
+        if search:
+            keyboard_key = search.group(1)
+
+        fake_logs.append((line, {'keys': keyboard_key}))
+    return fake_logs
+
+
+async def log_forever(fake_log_messages: List[Tuple[str, Dict]]):
+    """Test mode async log generator coroutine that runs forever."""
+    _ROOT_LOG.info('Fake log device connected.')
+    start_time = time.time()
+    message_count = 0
+
+    # Fake module column names.
+    module_names = ['APP', 'RADIO', 'BAT', 'USB', 'CPU']
+    while True:
+        if message_count > 32 or message_count < 2:
+            await asyncio.sleep(.1)
+        fake_log = random.choice(fake_log_messages)
+
+        module_name = module_names[message_count % len(module_names)]
+        _FAKE_DEVICE_LOG.info(
+            fake_log[0],
+            extra=dict(extra_metadata_fields=dict(module=module_name,
+                                                  file='fake_app.cc',
+                                                  timestamp=time.time() -
+                                                  start_time,
+                                                  **fake_log[1])))
+        message_count += 1
diff --git a/pw_console/testing.rst b/pw_console/testing.rst
index cfbf6e3..982ad11 100644
--- a/pw_console/testing.rst
+++ b/pw_console/testing.rst
@@ -35,7 +35,7 @@
      - ✅
 
    * - 1
-     - Click the :guilabel:`Fake Device Logs` window title
+     - Click the :guilabel:`Fake Device` window title
      - Log pane is focused
      - |checkbox|
 
@@ -140,7 +140,7 @@
      - ✅
 
    * - 1
-     - Click the :guilabel:`Fake Device Logs` window title
+     - Click the :guilabel:`Fake Device` window title
      - Log pane is focused
      - |checkbox|
 
@@ -402,12 +402,12 @@
      - ✅
 
    * - 1
-     - | Click the :guilabel:`Fake Device Logs` window title
+     - | Click the :guilabel:`Fake Device` window title
      - Log pane is focused
      - |checkbox|
 
    * - 2
-     - | Click the menu :guilabel:`Windows > #: Fake Device Logs...`
+     - | Click the menu :guilabel:`Windows > #: Fake Device...`
        | Click :guilabel:`Duplicate pane`
      - | 3 panes are visible:
        | Log pane on top
@@ -443,7 +443,7 @@
      - |checkbox|
 
    * - 7
-     - | Click the menu :guilabel:`Windows > #: Fake Device Logs...`
+     - | Click the menu :guilabel:`Windows > #: Fake Device...`
        | Click :guilabel:`Remove pane`
      - | 2 panes are visible:
        | Repl pane on the top
@@ -520,7 +520,7 @@
      - ✅
 
    * - 1
-     - | Click the :guilabel:`Fake Device Logs` window
+     - | Click the :guilabel:`Fake Device` window
      - Log pane is focused
      - |checkbox|
 
@@ -540,7 +540,7 @@
 
    * - 4
      - Click the :guilabel:`View > Move Window Right`
-     - :guilabel:`Fake Device Logs` should appear in a right side split
+     - :guilabel:`Fake Device` should appear in a right side split
      - |checkbox|
 
    * - 5
@@ -582,7 +582,7 @@
      - ✅
 
    * - 1
-     - | Click the :guilabel:`Fake Device Logs` window title
+     - | Click the :guilabel:`Fake Device` window title
      - Log pane is focused
      - |checkbox|