home · contact · privacy
Add basic TUI drawing testing. master
authorChristian Heller <c.heller@plomlompom.de>
Tue, 14 Oct 2025 14:18:14 +0000 (16:18 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Tue, 14 Oct 2025 14:18:14 +0000 (16:18 +0200)
src/ircplom/testing.py
src/ircplom/tui_base.py
src/tests/tui_draw.test [new file with mode: 0644]

index 71b78dc1fa885770f19a5e25a2d862618d9e6110..58b39fa8ef3377791c7fe704f1c8fec45c2d9f30 100644 (file)
@@ -3,6 +3,7 @@ from contextlib import contextmanager
 from queue import SimpleQueue, Empty as QueueEmpty
 from pathlib import Path
 from time import sleep
 from queue import SimpleQueue, Empty as QueueEmpty
 from pathlib import Path
 from time import sleep
+from textwrap import wrap
 from typing import Callable, Generator, Iterator, Optional
 from ircplom.events import Event, Loop, QueueMixin
 from ircplom.client import IrcConnection, IrcConnSetup
 from typing import Callable, Generator, Iterator, Optional
 from ircplom.events import Event, Loop, QueueMixin
 from ircplom.client import IrcConnection, IrcConnSetup
@@ -22,6 +23,7 @@ class TestTerminal(QueueMixin, TerminalInterface):
     def __init__(self, **kwargs) -> None:
         super().__init__(**kwargs)
         self._q_keypresses: SimpleQueue = SimpleQueue()
     def __init__(self, **kwargs) -> None:
         super().__init__(**kwargs)
         self._q_keypresses: SimpleQueue = SimpleQueue()
+        self._screen: list[list[tuple[tuple[str, ...], str]]] = []
 
     @contextmanager
     def setup(self) -> Generator:
 
     @contextmanager
     def setup(self) -> Generator:
@@ -32,18 +34,26 @@ class TestTerminal(QueueMixin, TerminalInterface):
         pass
 
     def set_size_hide_cursor(self) -> None:
         pass
 
     def set_size_hide_cursor(self) -> None:
-        self.size = TerminalInterface.__annotations__['size'](0, 0)
+        self.size = TerminalInterface.__annotations__['size'](24, 80)
+        self._screen.clear()
+        for _ in range(self.size.y):
+            line: list[tuple[tuple[str, ...], str]] = []
+            self._screen += [line]
+            for _ in range(self.size.x):
+                line += [(tuple(), ' ')]
 
     def wrap(self, line: str) -> list[str]:
 
     def wrap(self, line: str) -> list[str]:
-        return []
-
-    def write(self,
-              msg: str = '',
-              start_y: Optional[int] = None,
-              attributes: str = '',
-              padding: bool = True
-              ) -> None:
-        pass
+        return wrap(line, width=self.size.x, subsequent_indent=' '*4)
+
+    def _length_to_terminal(self, text: str) -> int:
+        return len(text)
+
+    def _write_w_attrs(self, text: str, attrs: tuple[str, ...]) -> None:
+        for i, c in enumerate(text):
+            self._screen[self._cursor_yx.y][self._cursor_yx.x + i] = (attrs, c)
+
+    def _truncate(self, text: str, size: int) -> str:
+        return text[:size]
 
     def _get_keypresses(self) -> Iterator[Optional[TuiEvent]]:
         while True:
 
     def _get_keypresses(self) -> Iterator[Optional[TuiEvent]]:
         while True:
@@ -55,6 +65,19 @@ class TestTerminal(QueueMixin, TerminalInterface):
             yield TuiEvent.affector('handle_keyboard_event'
                                     ).kw(typed_in=to_yield)
 
             yield TuiEvent.affector('handle_keyboard_event'
                                     ).kw(typed_in=to_yield)
 
+    def assert_screen_line(self, y: int, x: int, text: str, attrs_str: str
+                           ) -> None:
+        'Assert test screen at (y,x) shows text, with attrs of attrs_str set.'
+        assert len(text) + x <= self.size.x
+        assert 0 <= y < self.size.y
+        for idx, cell_expected in enumerate(
+                (self._attrs_tuple_from_str(attrs_str), c) for c in text):
+            cell_found = self._screen[y][x + idx]
+            info = (x + idx, 'EXPECTED/FOUND',
+                    cell_expected, cell_found,
+                    text, ''.join(t[1] for t in self._screen[y][x:]))
+            assert cell_expected == cell_found, info
+
 
 class _FakeIrcConnection(IrcConnection):
 
 
 class _FakeIrcConnection(IrcConnection):
 
@@ -115,10 +138,12 @@ _CHAR_RANGE_DATA_SEP = ' '
 _CHAR_WIN_ID_SEP = ','
 _TOK_REPEAT = 'repeat'
 _TOK_WAIT = 'wait'
 _CHAR_WIN_ID_SEP = ','
 _TOK_REPEAT = 'repeat'
 _TOK_WAIT = 'wait'
+_TOK_TUI = 'TUI'
 
 
 class _Playbook:
     put_keypress: Optional[Callable] = None
 
 
 class _Playbook:
     put_keypress: Optional[Callable] = None
+    assert_screen_line: Optional[Callable] = None
 
     def __init__(self, path: Path, get_client: Callable, verbose: bool
                  ) -> None:
 
     def __init__(self, path: Path, get_client: Callable, verbose: bool
                  ) -> None:
@@ -242,6 +267,13 @@ class _Playbook:
                 client = self._get_client(int(context[1:]))
                 assert isinstance(client.conn, _FakeIrcConnection), client.conn
                 client.conn.put_server_msg(msg)
                 client = self._get_client(int(context[1:]))
                 assert isinstance(client.conn, _FakeIrcConnection), client.conn
                 client.conn.put_server_msg(msg)
+            elif context == _TOK_TUI:
+                assert self.assert_screen_line is not None
+                position, attrs_str, msg = msg.split('.', maxsplit=2)
+                y, x = ((int(item) for item in position.split(','))
+                        if ',' in position
+                        else (int(position), 0))
+                self.assert_screen_line(y, x, msg, attrs_str)
             elif context == _TOK_WAIT:
                 assert msg.isdigit()
                 sleep(int(msg))
             elif context == _TOK_WAIT:
                 assert msg.isdigit()
                 sleep(int(msg))
@@ -274,12 +306,10 @@ class TestingClientTui(ClientTui):
                                    get_client=lambda idx: self._clients[idx])
         super().__init__(**kwargs)
         assert isinstance(self._term, TestTerminal)
                                    get_client=lambda idx: self._clients[idx])
         super().__init__(**kwargs)
         assert isinstance(self._term, TestTerminal)
+        self._playbook.assert_screen_line = self._term.assert_screen_line
         self._playbook.put_keypress = self._term._q_keypresses.put
         self._playbook.ensure_has_started()  # if .__init__ didn't yet by log()
 
         self._playbook.put_keypress = self._term._q_keypresses.put
         self._playbook.ensure_has_started()  # if .__init__ didn't yet by log()
 
-    def _switch_window(self, idx: int) -> None:
-        self._window_idx = idx
-
     @classmethod
     def on_file(cls, path_test: Path, verbose: bool):
         'Return cls with ._path_test set.'
     @classmethod
     def on_file(cls, path_test: Path, verbose: bool):
         'Return cls with ._path_test set.'
@@ -294,6 +324,12 @@ class TestingClientTui(ClientTui):
 
     def log(self, msg: str, **kwargs) -> tuple[tuple[int, ...], str]:
         win_ids, logged_msg = super().log(msg, **kwargs)
 
     def log(self, msg: str, **kwargs) -> tuple[tuple[int, ...], str]:
         win_ids, logged_msg = super().log(msg, **kwargs)
+        # usually whatever called log would call .redraw_affected directly
+        # after, but for TUI display tests on the effects of .log that might
+        # follow directly in the playbook (i.e. will run before .log actually
+        # finishes, signaling to caller they should call .redraw_affected) we
+        # want this available immediately
+        self.redraw_affected()
         fmt, time_str, msg_sans_time = logged_msg.split(' ', maxsplit=2)
         msg_sans_time = fmt + ' ' + msg_sans_time
         assert len(time_str) == 8
         fmt, time_str, msg_sans_time = logged_msg.split(' ', maxsplit=2)
         msg_sans_time = fmt + ' ' + msg_sans_time
         assert len(time_str) == 8
index 12cfe9853254a7d994d061abffa4a785ee226eb5..bcea3c0d17c0abebd2e0a4d7a9b45d74254cadd8 100644 (file)
@@ -485,6 +485,7 @@ class TerminalInterface(ABC):
 
     def __init__(self, **kwargs) -> None:
         super().__init__(**kwargs)
 
     def __init__(self, **kwargs) -> None:
         super().__init__(**kwargs)
+        self._cursor_yx = _YX(0, 0)
 
     @abstractmethod
     @contextmanager
 
     @abstractmethod
     @contextmanager
@@ -503,7 +504,22 @@ class TerminalInterface(ABC):
     def wrap(self, line: str) -> list[str]:
         'Wrap line to list of lines fitting into terminal width.'
 
     def wrap(self, line: str) -> list[str]:
         'Wrap line to list of lines fitting into terminal width.'
 
+    @staticmethod
+    def _attrs_tuple_from_str(attrs_str: str) -> tuple[str, ...]:
+        return tuple(attr for attr in attrs_str.split(',') if attr)
+
+    @abstractmethod
+    def _length_to_terminal(self, text: str) -> int:
+        pass
+
     @abstractmethod
     @abstractmethod
+    def _write_w_attrs(self, text: str, attrs: tuple[str, ...]) -> None:
+        pass
+
+    @abstractmethod
+    def _truncate(self, text: str, size: int) -> str:
+        pass
+
     def write(self,
               msg: str = '',
               start_y: Optional[int] = None,
     def write(self,
               msg: str = '',
               start_y: Optional[int] = None,
@@ -511,6 +527,18 @@ class TerminalInterface(ABC):
               padding: bool = True
               ) -> None:
         'Print to terminal, with position, padding to line end, attributes.'
               padding: bool = True
               ) -> None:
         'Print to terminal, with position, padding to line end, attributes.'
+        if start_y is not None:
+            self._cursor_yx = _YX(start_y, 0)
+        # ._blessed.length can slow down things notably: only use where needed!
+        end_x = self._cursor_yx.x + self._length_to_terminal(msg)
+        len_padding = self.size.x - end_x
+        if len_padding < 0:
+            msg = self._truncate(msg, self.size.x - self._cursor_yx.x)
+        elif padding:
+            msg += ' ' * len_padding
+            end_x = self.size.x
+        self._write_w_attrs(msg, self._attrs_tuple_from_str(attributes))
+        self._cursor_yx = _YX(self._cursor_yx.y, end_x)
 
     @abstractmethod
     def _get_keypresses(self) -> Iterator[Optional[TuiEvent]]:
 
     @abstractmethod
     def _get_keypresses(self) -> Iterator[Optional[TuiEvent]]:
@@ -723,7 +751,6 @@ class Terminal(QueueMixin, TerminalInterface):
     def __init__(self, **kwargs) -> None:
         super().__init__(**kwargs)
         self._blessed = BlessedTerminal()
     def __init__(self, **kwargs) -> None:
         super().__init__(**kwargs)
         self._blessed = BlessedTerminal()
-        self._cursor_yx = _YX(0, 0)
 
     @contextmanager
     def setup(self) -> Generator:
 
     @contextmanager
     def setup(self) -> Generator:
@@ -763,27 +790,16 @@ class Terminal(QueueMixin, TerminalInterface):
         return self._blessed.wrap(line, width=self.size.x,
                                   subsequent_indent=' '*4)
 
         return self._blessed.wrap(line, width=self.size.x,
                                   subsequent_indent=' '*4)
 
-    def write(self,
-              msg: str = '',
-              start_y: Optional[int] = None,
-              attributes: str = '',
-              padding: bool = True
-              ) -> None:
-        if start_y is not None:
-            self._cursor_yx = _YX(start_y, 0)
-        # ._blessed.length can slow down things notably: only use where needed!
-        end_x = self._cursor_yx.x + (len(msg) if msg.isascii()
-                                     else self._blessed.length(msg))
-        len_padding = self.size.x - end_x
-        if len_padding < 0:
-            msg = self._blessed.truncate(msg, self.size.x - self._cursor_yx.x)
-        elif padding:
-            msg += ' ' * len_padding
-            end_x = self.size.x
-        for attr in [attr for attr in attributes.split(',') if attr]:
-            msg = getattr(self._blessed, attr)(msg)
-        print(msg, end='')
-        self._cursor_yx = _YX(self._cursor_yx.y, end_x)
+    def _length_to_terminal(self, text: str) -> int:
+        return len(text) if text.isascii() else self._blessed.length(text)
+
+    def _write_w_attrs(self, text: str, attrs: tuple[str, ...]) -> None:
+        for attr in attrs:
+            text = getattr(self._blessed, attr)(text)
+        print(text, end='')
+
+    def _truncate(self, text: str, size: int) -> str:
+        return self._blessed.truncate(text, size)
 
     def _get_keypresses(self) -> Iterator[Optional[TuiEvent]]:
         '''Loop through keypresses from terminal, expand blessed's handling.
 
     def _get_keypresses(self) -> Iterator[Optional[TuiEvent]]:
         '''Loop through keypresses from terminal, expand blessed's handling.
diff --git a/src/tests/tui_draw.test b/src/tests/tui_draw.test
new file mode 100644 (file)
index 0000000..15587c2
--- /dev/null
@@ -0,0 +1,93 @@
+TUI 0.on_black.                                                                                
+TUI 1.on_black.                                                                                
+TUI 2.on_black.                                                                                
+TUI 3.on_black.                                                                                
+TUI 4.on_black.                                                                                
+TUI 5.on_black.                                                                                
+TUI 6.on_black.                                                                                
+TUI 7.on_black.                                                                                
+TUI 8.on_black.                                                                                
+TUI 9.on_black.                                                                                
+TUI 10.on_black.                                                                                
+TUI 11.on_black.                                                                                
+TUI 12.on_black.                                                                                
+TUI 13.on_black.                                                                                
+TUI 14.on_black.                                                                                
+TUI 15.on_black.                                                                                
+TUI 16.on_black.                                                                                
+TUI 17.on_black.                                                                                
+TUI 18.on_black.                                                                                
+TUI 19.on_black.                                                                                
+TUI 20.on_black.                                                                                
+TUI 21.on_black.                                                                                
+TUI 22..:start)=====================================================================([0]
+
+TUI 23,0..> 
+TUI 23,2.reverse. 
+TUI 23,3..                                                                             
+# nothing happening on empty command input
+> 
+TUI 0.on_black.                                                                                
+TUI 21.on_black.                                                                                
+TUI 22..:start)=====================================================================([0]
+
+TUI 23,2.reverse. 
+
+# non-empty command input starts log at bottom, with date above it
+> foo
+0 .!# invalid prompt command: not prefixed by /
+
+TUI 0.on_black.                                                                                
+TUI 20,0.on_black.20
+TUI 20,4.on_black.-
+TUI 20,7.on_black.-
+TUI 20,10.on_black.                                                                    
+TUI 21,0.on_black,bold,bright_red,bright_cyan..!# 
+TUI 21,13.on_black,bold,bright_red,bright_cyan.invalid prompt command: not prefixed by /                          
+TUI 22..:start)=====================================================================([0]
+TUI 23,0..> 
+TUI 23,2.reverse. 
+TUI 23,3..                                                                             
+
+# further inputs grow log upwards
+> /foo
+0 .!# invalid prompt command: /foo unknown
+> /foo
+0 .!# invalid prompt command: /foo unknown
+
+TUI 17.on_black.                                                                                
+TUI 18,0.on_black.20
+TUI 19,13.on_black,bold,bright_red,bright_cyan.invalid prompt command: not prefixed by /                          
+TUI 20,13.on_black,bold,bright_red,bright_cyan.invalid prompt command: /foo unknown       
+TUI 21,13.on_black,bold,bright_red,bright_cyan.invalid prompt command: /foo unknown       
+
+# check wrapping
+> /foo_0123456789_0123456789_01234567
+0 .!# invalid prompt command: /foo_0123456789_0123456789_01234567 unknown
+TUI 21,13.on_black,bold,bright_red,bright_cyan.invalid prompt command: /foo_0123456789_0123456789_01234567 unknown
+> /foo_0123456789_0123456789_012345678
+0 .!# invalid prompt command: /foo_0123456789_0123456789_012345678 unknown
+TUI 20,13.on_black,bold,bright_red,bright_cyan.invalid prompt command: /foo_0123456789_0123456789_012345678       
+TUI 21,0.on_black,bold,bright_red,bright_cyan.    unknown                                                                     
+# # check scrolling
+# > /window.prompt.scroll up
+# > /foo_0123456789_0123456789_012345678
+# 0 .!# invalid prompt command: /foo_0123456789_0123456789_012345678 unknown
+# # > /foo_0123456789_0123456789_012345678
+# # TUI 21.on_black.                                                                                
+# # > /foo_0123456789_0123456789_012345678
+# # 0 .!# invalid prompt command: /foo_0123456789_0123456789_012345678 unknown
+# # > /foo_0123456789_0123456789_012345678
+# # 0 .!# invalid prompt command: /foo_0123456789_0123456789_012345678 unknown
+# # > /foo_0123456789_0123456789_012345678
+# # 0 .!# invalid prompt command: /foo_0123456789_0123456789_012345678 unknown
+# # > /foo_0123456789_0123456789_012345678
+# # 0 .!# invalid prompt command: /foo_0123456789_0123456789_012345678 unknown
+# # > /foo_0123456789_0123456789_012345678
+# # 0 .!# invalid prompt command: /foo_0123456789_0123456789_012345678 unknown
+# # > /foo_0123456789_0123456789_012345678
+# # 0 .!# invalid prompt command: /foo_0123456789_0123456789_012345678 unknown
+# # > /foo_0123456789_0123456789_012345678
+# # TUI 0.on_black.                                                                                
+> /quit
+0 ..<