From: Christian Heller Date: Tue, 14 Oct 2025 14:18:14 +0000 (+0200) Subject: Add basic TUI drawing testing. X-Git-Url: https://plomlompom.com/repos/%7B%7B%20web_path%20%7D%7D/ledger?a=commitdiff_plain;ds=inline;p=ircplom Add basic TUI drawing testing. --- diff --git a/src/ircplom/testing.py b/src/ircplom/testing.py index 71b78dc..58b39fa 100644 --- a/src/ircplom/testing.py +++ b/src/ircplom/testing.py @@ -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 diff --git a/src/ircplom/tui_base.py b/src/ircplom/tui_base.py index 12cfe98..bcea3c0 100644 --- a/src/ircplom/tui_base.py +++ b/src/ircplom/tui_base.py @@ -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 index 0000000..15587c2 --- /dev/null +++ b/src/tests/tui_draw.test @@ -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 ..<