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 textwrap import wrap
 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()
+        self._screen: list[list[tuple[tuple[str, ...], str]]] = []
 
     @contextmanager
     def setup(self) -> Generator:
@@ -32,18 +34,26 @@ class TestTerminal(QueueMixin, TerminalInterface):
         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]:
-        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:
@@ -55,6 +65,19 @@ class TestTerminal(QueueMixin, TerminalInterface):
             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):
 
@@ -115,10 +138,12 @@ _CHAR_RANGE_DATA_SEP = ' '
 _CHAR_WIN_ID_SEP = ','
 _TOK_REPEAT = 'repeat'
 _TOK_WAIT = 'wait'
+_TOK_TUI = 'TUI'
 
 
 class _Playbook:
     put_keypress: Optional[Callable] = None
+    assert_screen_line: Optional[Callable] = 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)
+            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))
@@ -274,12 +306,10 @@ class TestingClientTui(ClientTui):
                                    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()
 
-    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.'
@@ -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)
+        # 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
index 12cfe9853254a7d994d061abffa4a785ee226eb5..bcea3c0d17c0abebd2e0a4d7a9b45d74254cadd8 100644 (file)
@@ -485,6 +485,7 @@ class TerminalInterface(ABC):
 
     def __init__(self, **kwargs) -> None:
         super().__init__(**kwargs)
+        self._cursor_yx = _YX(0, 0)
 
     @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.'
 
+    @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
+    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,
@@ -511,6 +527,18 @@ class TerminalInterface(ABC):
               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]]:
@@ -723,7 +751,6 @@ class Terminal(QueueMixin, TerminalInterface):
     def __init__(self, **kwargs) -> None:
         super().__init__(**kwargs)
         self._blessed = BlessedTerminal()
-        self._cursor_yx = _YX(0, 0)
 
     @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)
 
-    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.
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 ..<