diff options
Diffstat (limited to 'tests')
-rw-r--r-- | tests/test_async_generator.py | 28 | ||||
-rw-r--r-- | tests/test_buffer.py | 112 | ||||
-rw-r--r-- | tests/test_cli.py | 941 | ||||
-rw-r--r-- | tests/test_completion.py | 469 | ||||
-rw-r--r-- | tests/test_document.py | 69 | ||||
-rw-r--r-- | tests/test_filter.py | 131 | ||||
-rw-r--r-- | tests/test_formatted_text.py | 286 | ||||
-rw-r--r-- | tests/test_history.py | 103 | ||||
-rw-r--r-- | tests/test_inputstream.py | 141 | ||||
-rw-r--r-- | tests/test_key_binding.py | 200 | ||||
-rw-r--r-- | tests/test_layout.py | 53 | ||||
-rw-r--r-- | tests/test_memory_leaks.py | 35 | ||||
-rw-r--r-- | tests/test_print_formatted_text.py | 92 | ||||
-rw-r--r-- | tests/test_regular_languages.py | 102 | ||||
-rw-r--r-- | tests/test_shortcuts.py | 68 | ||||
-rw-r--r-- | tests/test_style.py | 276 | ||||
-rw-r--r-- | tests/test_style_transformation.py | 51 | ||||
-rw-r--r-- | tests/test_utils.py | 78 | ||||
-rw-r--r-- | tests/test_vt100_output.py | 20 | ||||
-rw-r--r-- | tests/test_widgets.py | 20 | ||||
-rw-r--r-- | tests/test_yank_nth_arg.py | 86 |
21 files changed, 3361 insertions, 0 deletions
diff --git a/tests/test_async_generator.py b/tests/test_async_generator.py new file mode 100644 index 0000000..8c95f8c --- /dev/null +++ b/tests/test_async_generator.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from asyncio import run + +from prompt_toolkit.eventloop import generator_to_async_generator + + +def _sync_generator(): + yield 1 + yield 10 + + +def test_generator_to_async_generator(): + """ + Test conversion of sync to async generator. + This should run the synchronous parts in a background thread. + """ + async_gen = generator_to_async_generator(_sync_generator) + + items = [] + + async def consume_async_generator(): + async for item in async_gen: + items.append(item) + + # Run the event loop until all items are collected. + run(consume_async_generator()) + assert items == [1, 10] diff --git a/tests/test_buffer.py b/tests/test_buffer.py new file mode 100644 index 0000000..e636137 --- /dev/null +++ b/tests/test_buffer.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import pytest + +from prompt_toolkit.buffer import Buffer + + +@pytest.fixture +def _buffer(): + buff = Buffer() + return buff + + +def test_initial(_buffer): + assert _buffer.text == "" + assert _buffer.cursor_position == 0 + + +def test_insert_text(_buffer): + _buffer.insert_text("some_text") + assert _buffer.text == "some_text" + assert _buffer.cursor_position == len("some_text") + + +def test_cursor_movement(_buffer): + _buffer.insert_text("some_text") + _buffer.cursor_left() + _buffer.cursor_left() + _buffer.cursor_left() + _buffer.cursor_right() + _buffer.insert_text("A") + + assert _buffer.text == "some_teAxt" + assert _buffer.cursor_position == len("some_teA") + + +def test_backspace(_buffer): + _buffer.insert_text("some_text") + _buffer.cursor_left() + _buffer.cursor_left() + _buffer.delete_before_cursor() + + assert _buffer.text == "some_txt" + assert _buffer.cursor_position == len("some_t") + + +def test_cursor_up(_buffer): + # Cursor up to a line thats longer. + _buffer.insert_text("long line1\nline2") + _buffer.cursor_up() + + assert _buffer.document.cursor_position == 5 + + # Going up when already at the top. + _buffer.cursor_up() + assert _buffer.document.cursor_position == 5 + + # Going up to a line that's shorter. + _buffer.reset() + _buffer.insert_text("line1\nlong line2") + + _buffer.cursor_up() + assert _buffer.document.cursor_position == 5 + + +def test_cursor_down(_buffer): + _buffer.insert_text("line1\nline2") + _buffer.cursor_position = 3 + + # Normally going down + _buffer.cursor_down() + assert _buffer.document.cursor_position == len("line1\nlin") + + # Going down to a line that's shorter. + _buffer.reset() + _buffer.insert_text("long line1\na\nb") + _buffer.cursor_position = 3 + + _buffer.cursor_down() + assert _buffer.document.cursor_position == len("long line1\na") + + +def test_join_next_line(_buffer): + _buffer.insert_text("line1\nline2\nline3") + _buffer.cursor_up() + _buffer.join_next_line() + + assert _buffer.text == "line1\nline2 line3" + + # Test when there is no '\n' in the text + _buffer.reset() + _buffer.insert_text("line1") + _buffer.cursor_position = 0 + _buffer.join_next_line() + + assert _buffer.text == "line1" + + +def test_newline(_buffer): + _buffer.insert_text("hello world") + _buffer.newline() + + assert _buffer.text == "hello world\n" + + +def test_swap_characters_before_cursor(_buffer): + _buffer.insert_text("hello world") + _buffer.cursor_left() + _buffer.cursor_left() + _buffer.swap_characters_before_cursor() + + assert _buffer.text == "hello wrold" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..3a16e9f --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,941 @@ +""" +These are almost end-to-end tests. They create a Prompt, feed it with some +input and check the result. +""" +from __future__ import annotations + +from functools import partial + +import pytest + +from prompt_toolkit.clipboard import ClipboardData, InMemoryClipboard +from prompt_toolkit.enums import EditingMode +from prompt_toolkit.filters import ViInsertMode +from prompt_toolkit.history import InMemoryHistory +from prompt_toolkit.input.defaults import create_pipe_input +from prompt_toolkit.input.vt100_parser import ANSI_SEQUENCES +from prompt_toolkit.key_binding.bindings.named_commands import prefix_meta +from prompt_toolkit.key_binding.key_bindings import KeyBindings +from prompt_toolkit.output import DummyOutput +from prompt_toolkit.shortcuts import PromptSession + + +def _history(): + h = InMemoryHistory() + h.append_string("line1 first input") + h.append_string("line2 second input") + h.append_string("line3 third input") + return h + + +def _feed_cli_with_input( + text, + editing_mode=EditingMode.EMACS, + clipboard=None, + history=None, + multiline=False, + check_line_ending=True, + key_bindings=None, +): + """ + Create a Prompt, feed it with the given user input and return the CLI + object. + + This returns a (result, Application) tuple. + """ + # If the given text doesn't end with a newline, the interface won't finish. + if check_line_ending: + assert text.endswith("\r") + + with create_pipe_input() as inp: + inp.send_text(text) + session = PromptSession( + input=inp, + output=DummyOutput(), + editing_mode=editing_mode, + history=history, + multiline=multiline, + clipboard=clipboard, + key_bindings=key_bindings, + ) + + _ = session.prompt() + return session.default_buffer.document, session.app + + +def test_simple_text_input(): + # Simple text input, followed by enter. + result, cli = _feed_cli_with_input("hello\r") + assert result.text == "hello" + assert cli.current_buffer.text == "hello" + + +def test_emacs_cursor_movements(): + """ + Test cursor movements with Emacs key bindings. + """ + # ControlA (beginning-of-line) + result, cli = _feed_cli_with_input("hello\x01X\r") + assert result.text == "Xhello" + + # ControlE (end-of-line) + result, cli = _feed_cli_with_input("hello\x01X\x05Y\r") + assert result.text == "XhelloY" + + # ControlH or \b + result, cli = _feed_cli_with_input("hello\x08X\r") + assert result.text == "hellX" + + # Delete. (Left, left, delete) + result, cli = _feed_cli_with_input("hello\x1b[D\x1b[D\x1b[3~\r") + assert result.text == "helo" + + # Left. + result, cli = _feed_cli_with_input("hello\x1b[DX\r") + assert result.text == "hellXo" + + # ControlA, right + result, cli = _feed_cli_with_input("hello\x01\x1b[CX\r") + assert result.text == "hXello" + + # ControlB (backward-char) + result, cli = _feed_cli_with_input("hello\x02X\r") + assert result.text == "hellXo" + + # ControlF (forward-char) + result, cli = _feed_cli_with_input("hello\x01\x06X\r") + assert result.text == "hXello" + + # ControlD: delete after cursor. + result, cli = _feed_cli_with_input("hello\x01\x04\r") + assert result.text == "ello" + + # ControlD at the end of the input ssshould not do anything. + result, cli = _feed_cli_with_input("hello\x04\r") + assert result.text == "hello" + + # Left, Left, ControlK (kill-line) + result, cli = _feed_cli_with_input("hello\x1b[D\x1b[D\x0b\r") + assert result.text == "hel" + + # Left, Left Esc- ControlK (kill-line, but negative) + result, cli = _feed_cli_with_input("hello\x1b[D\x1b[D\x1b-\x0b\r") + assert result.text == "lo" + + # ControlL: should not influence the result. + result, cli = _feed_cli_with_input("hello\x0c\r") + assert result.text == "hello" + + # ControlRight (forward-word) + result, cli = _feed_cli_with_input("hello world\x01X\x1b[1;5CY\r") + assert result.text == "XhelloY world" + + # ContrlolLeft (backward-word) + result, cli = _feed_cli_with_input("hello world\x1b[1;5DY\r") + assert result.text == "hello Yworld" + + # <esc>-f with argument. (forward-word) + result, cli = _feed_cli_with_input("hello world abc def\x01\x1b3\x1bfX\r") + assert result.text == "hello world abcX def" + + # <esc>-f with negative argument. (forward-word) + result, cli = _feed_cli_with_input("hello world abc def\x1b-\x1b3\x1bfX\r") + assert result.text == "hello Xworld abc def" + + # <esc>-b with argument. (backward-word) + result, cli = _feed_cli_with_input("hello world abc def\x1b3\x1bbX\r") + assert result.text == "hello Xworld abc def" + + # <esc>-b with negative argument. (backward-word) + result, cli = _feed_cli_with_input("hello world abc def\x01\x1b-\x1b3\x1bbX\r") + assert result.text == "hello world abc Xdef" + + # ControlW (kill-word / unix-word-rubout) + result, cli = _feed_cli_with_input("hello world\x17\r") + assert result.text == "hello " + assert cli.clipboard.get_data().text == "world" + + result, cli = _feed_cli_with_input("test hello world\x1b2\x17\r") + assert result.text == "test " + + # Escape Backspace (unix-word-rubout) + result, cli = _feed_cli_with_input("hello world\x1b\x7f\r") + assert result.text == "hello " + assert cli.clipboard.get_data().text == "world" + + result, cli = _feed_cli_with_input("hello world\x1b\x08\r") + assert result.text == "hello " + assert cli.clipboard.get_data().text == "world" + + # Backspace (backward-delete-char) + result, cli = _feed_cli_with_input("hello world\x7f\r") + assert result.text == "hello worl" + assert result.cursor_position == len("hello worl") + + result, cli = _feed_cli_with_input("hello world\x08\r") + assert result.text == "hello worl" + assert result.cursor_position == len("hello worl") + + # Delete (delete-char) + result, cli = _feed_cli_with_input("hello world\x01\x1b[3~\r") + assert result.text == "ello world" + assert result.cursor_position == 0 + + # Escape-\\ (delete-horizontal-space) + result, cli = _feed_cli_with_input("hello world\x1b8\x02\x1b\\\r") + assert result.text == "helloworld" + assert result.cursor_position == len("hello") + + +def test_emacs_kill_multiple_words_and_paste(): + # Using control-w twice should place both words on the clipboard. + result, cli = _feed_cli_with_input( + "hello world test\x17\x17--\x19\x19\r" # Twice c-w. Twice c-y. + ) + assert result.text == "hello --world testworld test" + assert cli.clipboard.get_data().text == "world test" + + # Using alt-d twice should place both words on the clipboard. + result, cli = _feed_cli_with_input( + "hello world test" + "\x1bb\x1bb" # Twice left. + "\x1bd\x1bd" # Twice kill-word. + "abc" + "\x19" # Paste. + "\r" + ) + assert result.text == "hello abcworld test" + assert cli.clipboard.get_data().text == "world test" + + +def test_interrupts(): + # ControlC: raise KeyboardInterrupt. + with pytest.raises(KeyboardInterrupt): + result, cli = _feed_cli_with_input("hello\x03\r") + + with pytest.raises(KeyboardInterrupt): + result, cli = _feed_cli_with_input("hello\x03\r") + + # ControlD without any input: raises EOFError. + with pytest.raises(EOFError): + result, cli = _feed_cli_with_input("\x04\r") + + +def test_emacs_yank(): + # ControlY (yank) + c = InMemoryClipboard(ClipboardData("XYZ")) + result, cli = _feed_cli_with_input("hello\x02\x19\r", clipboard=c) + assert result.text == "hellXYZo" + assert result.cursor_position == len("hellXYZ") + + +def test_quoted_insert(): + # ControlQ - ControlB (quoted-insert) + result, cli = _feed_cli_with_input("hello\x11\x02\r") + assert result.text == "hello\x02" + + +def test_transformations(): + # Meta-c (capitalize-word) + result, cli = _feed_cli_with_input("hello world\01\x1bc\r") + assert result.text == "Hello world" + assert result.cursor_position == len("Hello") + + # Meta-u (uppercase-word) + result, cli = _feed_cli_with_input("hello world\01\x1bu\r") + assert result.text == "HELLO world" + assert result.cursor_position == len("Hello") + + # Meta-u (downcase-word) + result, cli = _feed_cli_with_input("HELLO WORLD\01\x1bl\r") + assert result.text == "hello WORLD" + assert result.cursor_position == len("Hello") + + # ControlT (transpose-chars) + result, cli = _feed_cli_with_input("hello\x14\r") + assert result.text == "helol" + assert result.cursor_position == len("hello") + + # Left, Left, Control-T (transpose-chars) + result, cli = _feed_cli_with_input("abcde\x1b[D\x1b[D\x14\r") + assert result.text == "abdce" + assert result.cursor_position == len("abcd") + + +def test_emacs_other_bindings(): + # Transpose characters. + result, cli = _feed_cli_with_input("abcde\x14X\r") # Ctrl-T + assert result.text == "abcedX" + + # Left, Left, Transpose. (This is slightly different.) + result, cli = _feed_cli_with_input("abcde\x1b[D\x1b[D\x14X\r") + assert result.text == "abdcXe" + + # Clear before cursor. + result, cli = _feed_cli_with_input("hello\x1b[D\x1b[D\x15X\r") + assert result.text == "Xlo" + + # unix-word-rubout: delete word before the cursor. + # (ControlW). + result, cli = _feed_cli_with_input("hello world test\x17X\r") + assert result.text == "hello world X" + + result, cli = _feed_cli_with_input("hello world /some/very/long/path\x17X\r") + assert result.text == "hello world X" + + # (with argument.) + result, cli = _feed_cli_with_input("hello world test\x1b2\x17X\r") + assert result.text == "hello X" + + result, cli = _feed_cli_with_input("hello world /some/very/long/path\x1b2\x17X\r") + assert result.text == "hello X" + + # backward-kill-word: delete word before the cursor. + # (Esc-ControlH). + result, cli = _feed_cli_with_input("hello world /some/very/long/path\x1b\x08X\r") + assert result.text == "hello world /some/very/long/X" + + # (with arguments.) + result, cli = _feed_cli_with_input( + "hello world /some/very/long/path\x1b3\x1b\x08X\r" + ) + assert result.text == "hello world /some/very/X" + + +def test_controlx_controlx(): + # At the end: go to the start of the line. + result, cli = _feed_cli_with_input("hello world\x18\x18X\r") + assert result.text == "Xhello world" + assert result.cursor_position == 1 + + # At the start: go to the end of the line. + result, cli = _feed_cli_with_input("hello world\x01\x18\x18X\r") + assert result.text == "hello worldX" + + # Left, Left Control-X Control-X: go to the end of the line. + result, cli = _feed_cli_with_input("hello world\x1b[D\x1b[D\x18\x18X\r") + assert result.text == "hello worldX" + + +def test_emacs_history_bindings(): + # Adding a new item to the history. + history = _history() + result, cli = _feed_cli_with_input("new input\r", history=history) + assert result.text == "new input" + history.get_strings()[-1] == "new input" + + # Go up in history, and accept the last item. + result, cli = _feed_cli_with_input("hello\x1b[A\r", history=history) + assert result.text == "new input" + + # Esc< (beginning-of-history) + result, cli = _feed_cli_with_input("hello\x1b<\r", history=history) + assert result.text == "line1 first input" + + # Esc> (end-of-history) + result, cli = _feed_cli_with_input( + "another item\x1b[A\x1b[a\x1b>\r", history=history + ) + assert result.text == "another item" + + # ControlUp (previous-history) + result, cli = _feed_cli_with_input("\x1b[1;5A\r", history=history) + assert result.text == "another item" + + # Esc< ControlDown (beginning-of-history, next-history) + result, cli = _feed_cli_with_input("\x1b<\x1b[1;5B\r", history=history) + assert result.text == "line2 second input" + + +def test_emacs_reverse_search(): + history = _history() + + # ControlR (reverse-search-history) + result, cli = _feed_cli_with_input("\x12input\r\r", history=history) + assert result.text == "line3 third input" + + # Hitting ControlR twice. + result, cli = _feed_cli_with_input("\x12input\x12\r\r", history=history) + assert result.text == "line2 second input" + + +def test_emacs_arguments(): + """ + Test various combinations of arguments in Emacs mode. + """ + # esc 4 + result, cli = _feed_cli_with_input("\x1b4x\r") + assert result.text == "xxxx" + + # esc 4 4 + result, cli = _feed_cli_with_input("\x1b44x\r") + assert result.text == "x" * 44 + + # esc 4 esc 4 + result, cli = _feed_cli_with_input("\x1b4\x1b4x\r") + assert result.text == "x" * 44 + + # esc - right (-1 position to the right, equals 1 to the left.) + result, cli = _feed_cli_with_input("aaaa\x1b-\x1b[Cbbbb\r") + assert result.text == "aaabbbba" + + # esc - 3 right + result, cli = _feed_cli_with_input("aaaa\x1b-3\x1b[Cbbbb\r") + assert result.text == "abbbbaaa" + + # esc - - - 3 right + result, cli = _feed_cli_with_input("aaaa\x1b---3\x1b[Cbbbb\r") + assert result.text == "abbbbaaa" + + +def test_emacs_arguments_for_all_commands(): + """ + Test all Emacs commands with Meta-[0-9] arguments (both positive and + negative). No one should crash. + """ + for key in ANSI_SEQUENCES: + # Ignore BracketedPaste. This would hang forever, because it waits for + # the end sequence. + if key != "\x1b[200~": + try: + # Note: we add an 'X' after the key, because Ctrl-Q (quoted-insert) + # expects something to follow. We add an additional \r, because + # Ctrl-R and Ctrl-S (reverse-search) expect that. + result, cli = _feed_cli_with_input("hello\x1b4" + key + "X\r\r") + + result, cli = _feed_cli_with_input("hello\x1b-" + key + "X\r\r") + except KeyboardInterrupt: + # This exception should only be raised for Ctrl-C + assert key == "\x03" + + +def test_emacs_kill_ring(): + operations = ( + # abc ControlA ControlK + "abc\x01\x0b" + # def ControlA ControlK + "def\x01\x0b" + # ghi ControlA ControlK + "ghi\x01\x0b" + # ControlY (yank) + "\x19" + ) + + result, cli = _feed_cli_with_input(operations + "\r") + assert result.text == "ghi" + + result, cli = _feed_cli_with_input(operations + "\x1by\r") + assert result.text == "def" + + result, cli = _feed_cli_with_input(operations + "\x1by\x1by\r") + assert result.text == "abc" + + result, cli = _feed_cli_with_input(operations + "\x1by\x1by\x1by\r") + assert result.text == "ghi" + + +def test_emacs_selection(): + # Copy/paste empty selection should not do anything. + operations = ( + "hello" + # Twice left. + "\x1b[D\x1b[D" + # Control-Space + "\x00" + # ControlW (cut) + "\x17" + # ControlY twice. (paste twice) + "\x19\x19\r" + ) + + result, cli = _feed_cli_with_input(operations) + assert result.text == "hello" + + # Copy/paste one character. + operations = ( + "hello" + # Twice left. + "\x1b[D\x1b[D" + # Control-Space + "\x00" + # Right. + "\x1b[C" + # ControlW (cut) + "\x17" + # ControlA (Home). + "\x01" + # ControlY (paste) + "\x19\r" + ) + + result, cli = _feed_cli_with_input(operations) + assert result.text == "lhelo" + + +def test_emacs_insert_comment(): + # Test insert-comment (M-#) binding. + result, cli = _feed_cli_with_input("hello\x1b#", check_line_ending=False) + assert result.text == "#hello" + + result, cli = _feed_cli_with_input( + "hello\rworld\x1b#", check_line_ending=False, multiline=True + ) + assert result.text == "#hello\n#world" + + +def test_emacs_record_macro(): + operations = ( + " " + "\x18(" # Start recording macro. C-X( + "hello" + "\x18)" # Stop recording macro. + " " + "\x18e" # Execute macro. + "\x18e" # Execute macro. + "\r" + ) + + result, cli = _feed_cli_with_input(operations) + assert result.text == " hello hellohello" + + +def test_emacs_nested_macro(): + "Test calling the macro within a macro." + # Calling a macro within a macro should take the previous recording (if one + # exists), not the one that is in progress. + operations = ( + "\x18(" # Start recording macro. C-X( + "hello" + "\x18e" # Execute macro. + "\x18)" # Stop recording macro. + "\x18e" # Execute macro. + "\r" + ) + + result, cli = _feed_cli_with_input(operations) + assert result.text == "hellohello" + + operations = ( + "\x18(" # Start recording macro. C-X( + "hello" + "\x18)" # Stop recording macro. + "\x18(" # Start recording macro. C-X( + "\x18e" # Execute macro. + "world" + "\x18)" # Stop recording macro. + "\x01\x0b" # Delete all (c-a c-k). + "\x18e" # Execute macro. + "\r" + ) + + result, cli = _feed_cli_with_input(operations) + assert result.text == "helloworld" + + +def test_prefix_meta(): + # Test the prefix-meta command. + b = KeyBindings() + b.add("j", "j", filter=ViInsertMode())(prefix_meta) + + result, cli = _feed_cli_with_input( + "hellojjIX\r", key_bindings=b, editing_mode=EditingMode.VI + ) + assert result.text == "Xhello" + + +def test_bracketed_paste(): + result, cli = _feed_cli_with_input("\x1b[200~hello world\x1b[201~\r") + assert result.text == "hello world" + + result, cli = _feed_cli_with_input("\x1b[200~hello\rworld\x1b[201~\x1b\r") + assert result.text == "hello\nworld" + + # With \r\n endings. + result, cli = _feed_cli_with_input("\x1b[200~hello\r\nworld\x1b[201~\x1b\r") + assert result.text == "hello\nworld" + + # With \n endings. + result, cli = _feed_cli_with_input("\x1b[200~hello\nworld\x1b[201~\x1b\r") + assert result.text == "hello\nworld" + + +def test_vi_cursor_movements(): + """ + Test cursor movements with Vi key bindings. + """ + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI) + + result, cli = feed("\x1b\r") + assert result.text == "" + assert cli.editing_mode == EditingMode.VI + + # Esc h a X + result, cli = feed("hello\x1bhaX\r") + assert result.text == "hellXo" + + # Esc I X + result, cli = feed("hello\x1bIX\r") + assert result.text == "Xhello" + + # Esc I X + result, cli = feed("hello\x1bIX\r") + assert result.text == "Xhello" + + # Esc 2hiX + result, cli = feed("hello\x1b2hiX\r") + assert result.text == "heXllo" + + # Esc 2h2liX + result, cli = feed("hello\x1b2h2liX\r") + assert result.text == "hellXo" + + # Esc \b\b + result, cli = feed("hello\b\b\r") + assert result.text == "hel" + + # Esc \b\b + result, cli = feed("hello\b\b\r") + assert result.text == "hel" + + # Esc 2h D + result, cli = feed("hello\x1b2hD\r") + assert result.text == "he" + + # Esc 2h rX \r + result, cli = feed("hello\x1b2hrX\r") + assert result.text == "heXlo" + + +def test_vi_operators(): + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI) + + # Esc g~0 + result, cli = feed("hello\x1bg~0\r") + assert result.text == "HELLo" + + # Esc gU0 + result, cli = feed("hello\x1bgU0\r") + assert result.text == "HELLo" + + # Esc d0 + result, cli = feed("hello\x1bd0\r") + assert result.text == "o" + + +def test_vi_text_objects(): + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI) + + # Esc gUgg + result, cli = feed("hello\x1bgUgg\r") + assert result.text == "HELLO" + + # Esc gUU + result, cli = feed("hello\x1bgUU\r") + assert result.text == "HELLO" + + # Esc di( + result, cli = feed("before(inside)after\x1b8hdi(\r") + assert result.text == "before()after" + + # Esc di[ + result, cli = feed("before[inside]after\x1b8hdi[\r") + assert result.text == "before[]after" + + # Esc da( + result, cli = feed("before(inside)after\x1b8hda(\r") + assert result.text == "beforeafter" + + +def test_vi_digraphs(): + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI) + + # C-K o/ + result, cli = feed("hello\x0bo/\r") + assert result.text == "helloø" + + # C-K /o (reversed input.) + result, cli = feed("hello\x0b/o\r") + assert result.text == "helloø" + + # C-K e: + result, cli = feed("hello\x0be:\r") + assert result.text == "helloë" + + # C-K xxy (Unknown digraph.) + result, cli = feed("hello\x0bxxy\r") + assert result.text == "helloy" + + +def test_vi_block_editing(): + "Test Vi Control-V style block insertion." + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI, multiline=True) + + operations = ( + # Six lines of text. + "-line1\r-line2\r-line3\r-line4\r-line5\r-line6" + # Go to the second character of the second line. + "\x1bkkkkkkkj0l" + # Enter Visual block mode. + "\x16" + # Go down two more lines. + "jj" + # Go 3 characters to the right. + "lll" + # Go to insert mode. + "insert" # (Will be replaced.) + # Insert stars. + "***" + # Escape again. + "\x1b\r" + ) + + # Control-I + result, cli = feed(operations.replace("insert", "I")) + + assert result.text == "-line1\n-***line2\n-***line3\n-***line4\n-line5\n-line6" + + # Control-A + result, cli = feed(operations.replace("insert", "A")) + + assert result.text == "-line1\n-line***2\n-line***3\n-line***4\n-line5\n-line6" + + +def test_vi_block_editing_empty_lines(): + "Test block editing on empty lines." + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI, multiline=True) + + operations = ( + # Six empty lines. + "\r\r\r\r\r" + # Go to beginning of the document. + "\x1bgg" + # Enter Visual block mode. + "\x16" + # Go down two more lines. + "jj" + # Go 3 characters to the right. + "lll" + # Go to insert mode. + "insert" # (Will be replaced.) + # Insert stars. + "***" + # Escape again. + "\x1b\r" + ) + + # Control-I + result, cli = feed(operations.replace("insert", "I")) + + assert result.text == "***\n***\n***\n\n\n" + + # Control-A + result, cli = feed(operations.replace("insert", "A")) + + assert result.text == "***\n***\n***\n\n\n" + + +def test_vi_visual_line_copy(): + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI, multiline=True) + + operations = ( + # Three lines of text. + "-line1\r-line2\r-line3\r-line4\r-line5\r-line6" + # Go to the second character of the second line. + "\x1bkkkkkkkj0l" + # Enter Visual linemode. + "V" + # Go down one line. + "j" + # Go 3 characters to the right (should not do much). + "lll" + # Copy this block. + "y" + # Go down one line. + "j" + # Insert block twice. + "2p" + # Escape again. + "\x1b\r" + ) + + result, cli = feed(operations) + + assert ( + result.text + == "-line1\n-line2\n-line3\n-line4\n-line2\n-line3\n-line2\n-line3\n-line5\n-line6" + ) + + +def test_vi_visual_empty_line(): + """ + Test edge case with an empty line in Visual-line mode. + """ + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI, multiline=True) + + # 1. Delete first two lines. + operations = ( + # Three lines of text. The middle one is empty. + "hello\r\rworld" + # Go to the start. + "\x1bgg" + # Visual line and move down. + "Vj" + # Delete. + "d\r" + ) + result, cli = feed(operations) + assert result.text == "world" + + # 1. Delete middle line. + operations = ( + # Three lines of text. The middle one is empty. + "hello\r\rworld" + # Go to middle line. + "\x1bggj" + # Delete line + "Vd\r" + ) + + result, cli = feed(operations) + assert result.text == "hello\nworld" + + +def test_vi_character_delete_after_cursor(): + "Test 'x' keypress." + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI, multiline=True) + + # Delete one character. + result, cli = feed("abcd\x1bHx\r") + assert result.text == "bcd" + + # Delete multiple character.s + result, cli = feed("abcd\x1bH3x\r") + assert result.text == "d" + + # Delete on empty line. + result, cli = feed("\x1bo\x1bo\x1bggx\r") + assert result.text == "\n\n" + + # Delete multiple on empty line. + result, cli = feed("\x1bo\x1bo\x1bgg10x\r") + assert result.text == "\n\n" + + # Delete multiple on empty line. + result, cli = feed("hello\x1bo\x1bo\x1bgg3x\r") + assert result.text == "lo\n\n" + + +def test_vi_character_delete_before_cursor(): + "Test 'X' keypress." + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI, multiline=True) + + # Delete one character. + result, cli = feed("abcd\x1bX\r") + assert result.text == "abd" + + # Delete multiple character. + result, cli = feed("hello world\x1b3X\r") + assert result.text == "hello wd" + + # Delete multiple character on multiple lines. + result, cli = feed("hello\x1boworld\x1bgg$3X\r") + assert result.text == "ho\nworld" + + result, cli = feed("hello\x1boworld\x1b100X\r") + assert result.text == "hello\nd" + + # Delete on empty line. + result, cli = feed("\x1bo\x1bo\x1b10X\r") + assert result.text == "\n\n" + + +def test_vi_character_paste(): + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI) + + # Test 'p' character paste. + result, cli = feed("abcde\x1bhhxp\r") + assert result.text == "abdce" + assert result.cursor_position == 3 + + # Test 'P' character paste. + result, cli = feed("abcde\x1bhhxP\r") + assert result.text == "abcde" + assert result.cursor_position == 2 + + +def test_vi_temp_navigation_mode(): + """ + Test c-o binding: go for one action into navigation mode. + """ + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI) + + result, cli = feed("abcde" "\x0f" "3h" "x\r") # c-o # 3 times to the left. + assert result.text == "axbcde" + assert result.cursor_position == 2 + + result, cli = feed("abcde" "\x0f" "b" "x\r") # c-o # One word backwards. + assert result.text == "xabcde" + assert result.cursor_position == 1 + + # In replace mode + result, cli = feed( + "abcdef" + "\x1b" # Navigation mode. + "0l" # Start of line, one character to the right. + "R" # Replace mode + "78" + "\x0f" # c-o + "l" # One character forwards. + "9\r" + ) + assert result.text == "a78d9f" + assert result.cursor_position == 5 + + +def test_vi_macros(): + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI) + + # Record and execute macro. + result, cli = feed("\x1bqcahello\x1bq@c\r") + assert result.text == "hellohello" + assert result.cursor_position == 9 + + # Running unknown macro. + result, cli = feed("\x1b@d\r") + assert result.text == "" + assert result.cursor_position == 0 + + # When a macro is called within a macro. + # It shouldn't result in eternal recursion. + result, cli = feed("\x1bqxahello\x1b@xq@x\r") + assert result.text == "hellohello" + assert result.cursor_position == 9 + + # Nested macros. + result, cli = feed( + # Define macro 'x'. + "\x1bqxahello\x1bq" + # Define macro 'y' which calls 'x'. + "qya\x1b@xaworld\x1bq" + # Delete line. + "2dd" + # Execute 'y' + "@y\r" + ) + + assert result.text == "helloworld" + + +def test_accept_default(): + """ + Test `prompt(accept_default=True)`. + """ + with create_pipe_input() as inp: + session = PromptSession(input=inp, output=DummyOutput()) + result = session.prompt(default="hello", accept_default=True) + assert result == "hello" + + # Test calling prompt() for a second time. (We had an issue where the + # prompt reset between calls happened at the wrong time, breaking this.) + result = session.prompt(default="world", accept_default=True) + assert result == "world" diff --git a/tests/test_completion.py b/tests/test_completion.py new file mode 100644 index 0000000..8b3541a --- /dev/null +++ b/tests/test_completion.py @@ -0,0 +1,469 @@ +from __future__ import annotations + +import os +import re +import shutil +import tempfile +from contextlib import contextmanager + +from prompt_toolkit.completion import ( + CompleteEvent, + FuzzyWordCompleter, + NestedCompleter, + PathCompleter, + WordCompleter, + merge_completers, +) +from prompt_toolkit.document import Document + + +@contextmanager +def chdir(directory): + """Context manager for current working directory temporary change.""" + orig_dir = os.getcwd() + os.chdir(directory) + + try: + yield + finally: + os.chdir(orig_dir) + + +def write_test_files(test_dir, names=None): + """Write test files in test_dir using the names list.""" + names = names or range(10) + for i in names: + with open(os.path.join(test_dir, str(i)), "wb") as out: + out.write(b"") + + +def test_pathcompleter_completes_in_current_directory(): + completer = PathCompleter() + doc_text = "" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + assert len(completions) > 0 + + +def test_pathcompleter_completes_files_in_current_directory(): + # setup: create a test dir with 10 files + test_dir = tempfile.mkdtemp() + write_test_files(test_dir) + + expected = sorted(str(i) for i in range(10)) + + if not test_dir.endswith(os.path.sep): + test_dir += os.path.sep + + with chdir(test_dir): + completer = PathCompleter() + # this should complete on the cwd + doc_text = "" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + result = sorted(c.text for c in completions) + assert expected == result + + # cleanup + shutil.rmtree(test_dir) + + +def test_pathcompleter_completes_files_in_absolute_directory(): + # setup: create a test dir with 10 files + test_dir = tempfile.mkdtemp() + write_test_files(test_dir) + + expected = sorted(str(i) for i in range(10)) + + test_dir = os.path.abspath(test_dir) + if not test_dir.endswith(os.path.sep): + test_dir += os.path.sep + + completer = PathCompleter() + # force unicode + doc_text = str(test_dir) + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + result = sorted(c.text for c in completions) + assert expected == result + + # cleanup + shutil.rmtree(test_dir) + + +def test_pathcompleter_completes_directories_with_only_directories(): + # setup: create a test dir with 10 files + test_dir = tempfile.mkdtemp() + write_test_files(test_dir) + + # create a sub directory there + os.mkdir(os.path.join(test_dir, "subdir")) + + if not test_dir.endswith(os.path.sep): + test_dir += os.path.sep + + with chdir(test_dir): + completer = PathCompleter(only_directories=True) + doc_text = "" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + result = [c.text for c in completions] + assert ["subdir"] == result + + # check that there is no completion when passing a file + with chdir(test_dir): + completer = PathCompleter(only_directories=True) + doc_text = "1" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + assert [] == completions + + # cleanup + shutil.rmtree(test_dir) + + +def test_pathcompleter_respects_completions_under_min_input_len(): + # setup: create a test dir with 10 files + test_dir = tempfile.mkdtemp() + write_test_files(test_dir) + + # min len:1 and no text + with chdir(test_dir): + completer = PathCompleter(min_input_len=1) + doc_text = "" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + assert [] == completions + + # min len:1 and text of len 1 + with chdir(test_dir): + completer = PathCompleter(min_input_len=1) + doc_text = "1" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + result = [c.text for c in completions] + assert [""] == result + + # min len:0 and text of len 2 + with chdir(test_dir): + completer = PathCompleter(min_input_len=0) + doc_text = "1" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + result = [c.text for c in completions] + assert [""] == result + + # create 10 files with a 2 char long name + for i in range(10): + with open(os.path.join(test_dir, str(i) * 2), "wb") as out: + out.write(b"") + + # min len:1 and text of len 1 + with chdir(test_dir): + completer = PathCompleter(min_input_len=1) + doc_text = "2" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + result = sorted(c.text for c in completions) + assert ["", "2"] == result + + # min len:2 and text of len 1 + with chdir(test_dir): + completer = PathCompleter(min_input_len=2) + doc_text = "2" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + assert [] == completions + + # cleanup + shutil.rmtree(test_dir) + + +def test_pathcompleter_does_not_expanduser_by_default(): + completer = PathCompleter() + doc_text = "~" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + assert [] == completions + + +def test_pathcompleter_can_expanduser(): + completer = PathCompleter(expanduser=True) + doc_text = "~" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + assert len(completions) > 0 + + +def test_pathcompleter_can_apply_file_filter(): + # setup: create a test dir with 10 files + test_dir = tempfile.mkdtemp() + write_test_files(test_dir) + + # add a .csv file + with open(os.path.join(test_dir, "my.csv"), "wb") as out: + out.write(b"") + + file_filter = lambda f: f and f.endswith(".csv") + + with chdir(test_dir): + completer = PathCompleter(file_filter=file_filter) + doc_text = "" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + result = [c.text for c in completions] + assert ["my.csv"] == result + + # cleanup + shutil.rmtree(test_dir) + + +def test_pathcompleter_get_paths_constrains_path(): + # setup: create a test dir with 10 files + test_dir = tempfile.mkdtemp() + write_test_files(test_dir) + + # add a subdir with 10 other files with different names + subdir = os.path.join(test_dir, "subdir") + os.mkdir(subdir) + write_test_files(subdir, "abcdefghij") + + get_paths = lambda: ["subdir"] + + with chdir(test_dir): + completer = PathCompleter(get_paths=get_paths) + doc_text = "" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + result = [c.text for c in completions] + expected = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"] + assert expected == result + + # cleanup + shutil.rmtree(test_dir) + + +def test_word_completer_static_word_list(): + completer = WordCompleter(["abc", "def", "aaa"]) + + # Static list on empty input. + completions = completer.get_completions(Document(""), CompleteEvent()) + assert [c.text for c in completions] == ["abc", "def", "aaa"] + + # Static list on non-empty input. + completions = completer.get_completions(Document("a"), CompleteEvent()) + assert [c.text for c in completions] == ["abc", "aaa"] + + completions = completer.get_completions(Document("A"), CompleteEvent()) + assert [c.text for c in completions] == [] + + # Multiple words ending with space. (Accept all options) + completions = completer.get_completions(Document("test "), CompleteEvent()) + assert [c.text for c in completions] == ["abc", "def", "aaa"] + + # Multiple words. (Check last only.) + completions = completer.get_completions(Document("test a"), CompleteEvent()) + assert [c.text for c in completions] == ["abc", "aaa"] + + +def test_word_completer_ignore_case(): + completer = WordCompleter(["abc", "def", "aaa"], ignore_case=True) + completions = completer.get_completions(Document("a"), CompleteEvent()) + assert [c.text for c in completions] == ["abc", "aaa"] + + completions = completer.get_completions(Document("A"), CompleteEvent()) + assert [c.text for c in completions] == ["abc", "aaa"] + + +def test_word_completer_match_middle(): + completer = WordCompleter(["abc", "def", "abca"], match_middle=True) + completions = completer.get_completions(Document("bc"), CompleteEvent()) + assert [c.text for c in completions] == ["abc", "abca"] + + +def test_word_completer_sentence(): + # With sentence=True + completer = WordCompleter( + ["hello world", "www", "hello www", "hello there"], sentence=True + ) + completions = completer.get_completions(Document("hello w"), CompleteEvent()) + assert [c.text for c in completions] == ["hello world", "hello www"] + + # With sentence=False + completer = WordCompleter( + ["hello world", "www", "hello www", "hello there"], sentence=False + ) + completions = completer.get_completions(Document("hello w"), CompleteEvent()) + assert [c.text for c in completions] == ["www"] + + +def test_word_completer_dynamic_word_list(): + called = [0] + + def get_words(): + called[0] += 1 + return ["abc", "def", "aaa"] + + completer = WordCompleter(get_words) + + # Dynamic list on empty input. + completions = completer.get_completions(Document(""), CompleteEvent()) + assert [c.text for c in completions] == ["abc", "def", "aaa"] + assert called[0] == 1 + + # Static list on non-empty input. + completions = completer.get_completions(Document("a"), CompleteEvent()) + assert [c.text for c in completions] == ["abc", "aaa"] + assert called[0] == 2 + + +def test_word_completer_pattern(): + # With a pattern which support '.' + completer = WordCompleter( + ["abc", "a.b.c", "a.b", "xyz"], + pattern=re.compile(r"^([a-zA-Z0-9_.]+|[^a-zA-Z0-9_.\s]+)"), + ) + completions = completer.get_completions(Document("a."), CompleteEvent()) + assert [c.text for c in completions] == ["a.b.c", "a.b"] + + # Without pattern + completer = WordCompleter(["abc", "a.b.c", "a.b", "xyz"]) + completions = completer.get_completions(Document("a."), CompleteEvent()) + assert [c.text for c in completions] == [] + + +def test_fuzzy_completer(): + collection = [ + "migrations.py", + "django_migrations.py", + "django_admin_log.py", + "api_user.doc", + "user_group.doc", + "users.txt", + "accounts.txt", + "123.py", + "test123test.py", + ] + completer = FuzzyWordCompleter(collection) + completions = completer.get_completions(Document("txt"), CompleteEvent()) + assert [c.text for c in completions] == ["users.txt", "accounts.txt"] + + completions = completer.get_completions(Document("djmi"), CompleteEvent()) + assert [c.text for c in completions] == [ + "django_migrations.py", + "django_admin_log.py", + ] + + completions = completer.get_completions(Document("mi"), CompleteEvent()) + assert [c.text for c in completions] == [ + "migrations.py", + "django_migrations.py", + "django_admin_log.py", + ] + + completions = completer.get_completions(Document("user"), CompleteEvent()) + assert [c.text for c in completions] == [ + "user_group.doc", + "users.txt", + "api_user.doc", + ] + + completions = completer.get_completions(Document("123"), CompleteEvent()) + assert [c.text for c in completions] == ["123.py", "test123test.py"] + + completions = completer.get_completions(Document("miGr"), CompleteEvent()) + assert [c.text for c in completions] == [ + "migrations.py", + "django_migrations.py", + ] + + # Multiple words ending with space. (Accept all options) + completions = completer.get_completions(Document("test "), CompleteEvent()) + assert [c.text for c in completions] == collection + + # Multiple words. (Check last only.) + completions = completer.get_completions(Document("test txt"), CompleteEvent()) + assert [c.text for c in completions] == ["users.txt", "accounts.txt"] + + +def test_nested_completer(): + completer = NestedCompleter.from_nested_dict( + { + "show": { + "version": None, + "clock": None, + "interfaces": None, + "ip": {"interface": {"brief"}}, + }, + "exit": None, + } + ) + + # Empty input. + completions = completer.get_completions(Document(""), CompleteEvent()) + assert {c.text for c in completions} == {"show", "exit"} + + # One character. + completions = completer.get_completions(Document("s"), CompleteEvent()) + assert {c.text for c in completions} == {"show"} + + # One word. + completions = completer.get_completions(Document("show"), CompleteEvent()) + assert {c.text for c in completions} == {"show"} + + # One word + space. + completions = completer.get_completions(Document("show "), CompleteEvent()) + assert {c.text for c in completions} == {"version", "clock", "interfaces", "ip"} + + # One word + space + one character. + completions = completer.get_completions(Document("show i"), CompleteEvent()) + assert {c.text for c in completions} == {"ip", "interfaces"} + + # One space + one word + space + one character. + completions = completer.get_completions(Document(" show i"), CompleteEvent()) + assert {c.text for c in completions} == {"ip", "interfaces"} + + # Test nested set. + completions = completer.get_completions( + Document("show ip interface br"), CompleteEvent() + ) + assert {c.text for c in completions} == {"brief"} + + +def test_deduplicate_completer(): + def create_completer(deduplicate: bool): + return merge_completers( + [ + WordCompleter(["hello", "world", "abc", "def"]), + WordCompleter(["xyz", "xyz", "abc", "def"]), + ], + deduplicate=deduplicate, + ) + + completions = list( + create_completer(deduplicate=False).get_completions( + Document(""), CompleteEvent() + ) + ) + assert len(completions) == 8 + + completions = list( + create_completer(deduplicate=True).get_completions( + Document(""), CompleteEvent() + ) + ) + assert len(completions) == 5 diff --git a/tests/test_document.py b/tests/test_document.py new file mode 100644 index 0000000..d052d53 --- /dev/null +++ b/tests/test_document.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import pytest + +from prompt_toolkit.document import Document + + +@pytest.fixture +def document(): + return Document( + "line 1\n" + "line 2\n" + "line 3\n" + "line 4\n", len("line 1\n" + "lin") + ) + + +def test_current_char(document): + assert document.current_char == "e" + assert document.char_before_cursor == "n" + + +def test_text_before_cursor(document): + assert document.text_before_cursor == "line 1\nlin" + + +def test_text_after_cursor(document): + assert document.text_after_cursor == "e 2\n" + "line 3\n" + "line 4\n" + + +def test_lines(document): + assert document.lines == ["line 1", "line 2", "line 3", "line 4", ""] + + +def test_line_count(document): + assert document.line_count == 5 + + +def test_current_line_before_cursor(document): + assert document.current_line_before_cursor == "lin" + + +def test_current_line_after_cursor(document): + assert document.current_line_after_cursor == "e 2" + + +def test_current_line(document): + assert document.current_line == "line 2" + + +def test_cursor_position(document): + assert document.cursor_position_row == 1 + assert document.cursor_position_col == 3 + + d = Document("", 0) + assert d.cursor_position_row == 0 + assert d.cursor_position_col == 0 + + +def test_translate_index_to_position(document): + pos = document.translate_index_to_position(len("line 1\nline 2\nlin")) + + assert pos[0] == 2 + assert pos[1] == 3 + + pos = document.translate_index_to_position(0) + assert pos == (0, 0) + + +def test_is_cursor_at_the_end(document): + assert Document("hello", 5).is_cursor_at_the_end + assert not Document("hello", 4).is_cursor_at_the_end diff --git a/tests/test_filter.py b/tests/test_filter.py new file mode 100644 index 0000000..f7184c2 --- /dev/null +++ b/tests/test_filter.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import pytest + +from prompt_toolkit.filters import Always, Condition, Filter, Never, to_filter +from prompt_toolkit.filters.base import _AndList, _OrList + + +def test_never(): + assert not Never()() + + +def test_always(): + assert Always()() + + +def test_invert(): + assert not (~Always())() + assert ~Never()() + + c = ~Condition(lambda: False) + assert c() + + +def test_or(): + for a in (True, False): + for b in (True, False): + c1 = Condition(lambda: a) + c2 = Condition(lambda: b) + c3 = c1 | c2 + + assert isinstance(c3, Filter) + assert c3() == a or b + + +def test_and(): + for a in (True, False): + for b in (True, False): + c1 = Condition(lambda: a) + c2 = Condition(lambda: b) + c3 = c1 & c2 + + assert isinstance(c3, Filter) + assert c3() == (a and b) + + +def test_nested_and(): + for a in (True, False): + for b in (True, False): + for c in (True, False): + c1 = Condition(lambda: a) + c2 = Condition(lambda: b) + c3 = Condition(lambda: c) + c4 = (c1 & c2) & c3 + + assert isinstance(c4, Filter) + assert c4() == (a and b and c) + + +def test_nested_or(): + for a in (True, False): + for b in (True, False): + for c in (True, False): + c1 = Condition(lambda: a) + c2 = Condition(lambda: b) + c3 = Condition(lambda: c) + c4 = (c1 | c2) | c3 + + assert isinstance(c4, Filter) + assert c4() == (a or b or c) + + +def test_to_filter(): + f1 = to_filter(True) + f2 = to_filter(False) + f3 = to_filter(Condition(lambda: True)) + f4 = to_filter(Condition(lambda: False)) + + assert isinstance(f1, Filter) + assert isinstance(f2, Filter) + assert isinstance(f3, Filter) + assert isinstance(f4, Filter) + assert f1() + assert not f2() + assert f3() + assert not f4() + + with pytest.raises(TypeError): + to_filter(4) + + +def test_filter_cache_regression_1(): + # See: https://github.com/prompt-toolkit/python-prompt-toolkit/issues/1729 + + cond = Condition(lambda: True) + + # The use of a `WeakValueDictionary` caused this following expression to + # fail. The problem is that the nested `(a & a)` expression gets garbage + # collected between the two statements and is removed from our cache. + x = (cond & cond) & cond + y = (cond & cond) & cond + assert x == y + + +def test_filter_cache_regression_2(): + cond1 = Condition(lambda: True) + cond2 = Condition(lambda: True) + cond3 = Condition(lambda: True) + + x = (cond1 & cond2) & cond3 + y = (cond1 & cond2) & cond3 + assert x == y + + +def test_filter_remove_duplicates(): + cond1 = Condition(lambda: True) + cond2 = Condition(lambda: True) + + # When a condition is appended to itself using an `&` or `|` operator, it + # should not be present twice. Having it twice in the `_AndList` or + # `_OrList` will make them more expensive to evaluate. + + assert isinstance(cond1 & cond1, Condition) + assert isinstance(cond1 & cond1 & cond1, Condition) + assert isinstance(cond1 & cond1 & cond2, _AndList) + assert len((cond1 & cond1 & cond2).filters) == 2 + + assert isinstance(cond1 | cond1, Condition) + assert isinstance(cond1 | cond1 | cond1, Condition) + assert isinstance(cond1 | cond1 | cond2, _OrList) + assert len((cond1 | cond1 | cond2).filters) == 2 diff --git a/tests/test_formatted_text.py b/tests/test_formatted_text.py new file mode 100644 index 0000000..843aac1 --- /dev/null +++ b/tests/test_formatted_text.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +from prompt_toolkit.formatted_text import ( + ANSI, + HTML, + FormattedText, + PygmentsTokens, + Template, + merge_formatted_text, + to_formatted_text, +) +from prompt_toolkit.formatted_text.utils import split_lines + + +def test_basic_html(): + html = HTML("<i>hello</i>") + assert to_formatted_text(html) == [("class:i", "hello")] + + html = HTML("<i><b>hello</b></i>") + assert to_formatted_text(html) == [("class:i,b", "hello")] + + html = HTML("<i><b>hello</b>world<strong>test</strong></i>after") + assert to_formatted_text(html) == [ + ("class:i,b", "hello"), + ("class:i", "world"), + ("class:i,strong", "test"), + ("", "after"), + ] + + # It's important that `to_formatted_text` returns a `FormattedText` + # instance. Otherwise, `print_formatted_text` won't recognize it and will + # print a list literal instead. + assert isinstance(to_formatted_text(html), FormattedText) + + +def test_html_with_fg_bg(): + html = HTML('<style bg="ansired">hello</style>') + assert to_formatted_text(html) == [ + ("bg:ansired", "hello"), + ] + + html = HTML('<style bg="ansired" fg="#ff0000">hello</style>') + assert to_formatted_text(html) == [ + ("fg:#ff0000 bg:ansired", "hello"), + ] + + html = HTML( + '<style bg="ansired" fg="#ff0000">hello <world fg="ansiblue">world</world></style>' + ) + assert to_formatted_text(html) == [ + ("fg:#ff0000 bg:ansired", "hello "), + ("class:world fg:ansiblue bg:ansired", "world"), + ] + + +def test_ansi_formatting(): + value = ANSI("\x1b[32mHe\x1b[45mllo") + + assert to_formatted_text(value) == [ + ("ansigreen", "H"), + ("ansigreen", "e"), + ("ansigreen bg:ansimagenta", "l"), + ("ansigreen bg:ansimagenta", "l"), + ("ansigreen bg:ansimagenta", "o"), + ] + + # Bold and italic. + value = ANSI("\x1b[1mhe\x1b[0mllo") + + assert to_formatted_text(value) == [ + ("bold", "h"), + ("bold", "e"), + ("", "l"), + ("", "l"), + ("", "o"), + ] + + # Zero width escapes. + value = ANSI("ab\001cd\002ef") + + assert to_formatted_text(value) == [ + ("", "a"), + ("", "b"), + ("[ZeroWidthEscape]", "cd"), + ("", "e"), + ("", "f"), + ] + + assert isinstance(to_formatted_text(value), FormattedText) + + +def test_ansi_256_color(): + assert to_formatted_text(ANSI("\x1b[38;5;124mtest")) == [ + ("#af0000", "t"), + ("#af0000", "e"), + ("#af0000", "s"), + ("#af0000", "t"), + ] + + +def test_ansi_true_color(): + assert to_formatted_text(ANSI("\033[38;2;144;238;144m$\033[0;39;49m ")) == [ + ("#90ee90", "$"), + ("ansidefault bg:ansidefault", " "), + ] + + +def test_ansi_interpolation(): + # %-style interpolation. + value = ANSI("\x1b[1m%s\x1b[0m") % "hello\x1b" + assert to_formatted_text(value) == [ + ("bold", "h"), + ("bold", "e"), + ("bold", "l"), + ("bold", "l"), + ("bold", "o"), + ("bold", "?"), + ] + + value = ANSI("\x1b[1m%s\x1b[0m") % ("\x1bhello",) + assert to_formatted_text(value) == [ + ("bold", "?"), + ("bold", "h"), + ("bold", "e"), + ("bold", "l"), + ("bold", "l"), + ("bold", "o"), + ] + + value = ANSI("\x1b[32m%s\x1b[45m%s") % ("He", "\x1bllo") + assert to_formatted_text(value) == [ + ("ansigreen", "H"), + ("ansigreen", "e"), + ("ansigreen bg:ansimagenta", "?"), + ("ansigreen bg:ansimagenta", "l"), + ("ansigreen bg:ansimagenta", "l"), + ("ansigreen bg:ansimagenta", "o"), + ] + + # Format function. + value = ANSI("\x1b[32m{0}\x1b[45m{1}").format("He\x1b", "llo") + assert to_formatted_text(value) == [ + ("ansigreen", "H"), + ("ansigreen", "e"), + ("ansigreen", "?"), + ("ansigreen bg:ansimagenta", "l"), + ("ansigreen bg:ansimagenta", "l"), + ("ansigreen bg:ansimagenta", "o"), + ] + + value = ANSI("\x1b[32m{a}\x1b[45m{b}").format(a="\x1bHe", b="llo") + assert to_formatted_text(value) == [ + ("ansigreen", "?"), + ("ansigreen", "H"), + ("ansigreen", "e"), + ("ansigreen bg:ansimagenta", "l"), + ("ansigreen bg:ansimagenta", "l"), + ("ansigreen bg:ansimagenta", "o"), + ] + + value = ANSI("\x1b[32m{:02d}\x1b[45m{:.3f}").format(3, 3.14159) + assert to_formatted_text(value) == [ + ("ansigreen", "0"), + ("ansigreen", "3"), + ("ansigreen bg:ansimagenta", "3"), + ("ansigreen bg:ansimagenta", "."), + ("ansigreen bg:ansimagenta", "1"), + ("ansigreen bg:ansimagenta", "4"), + ("ansigreen bg:ansimagenta", "2"), + ] + + +def test_interpolation(): + value = Template(" {} ").format(HTML("<b>hello</b>")) + + assert to_formatted_text(value) == [ + ("", " "), + ("class:b", "hello"), + ("", " "), + ] + + value = Template("a{}b{}c").format(HTML("<b>hello</b>"), "world") + + assert to_formatted_text(value) == [ + ("", "a"), + ("class:b", "hello"), + ("", "b"), + ("", "world"), + ("", "c"), + ] + + +def test_html_interpolation(): + # %-style interpolation. + value = HTML("<b>%s</b>") % "&hello" + assert to_formatted_text(value) == [("class:b", "&hello")] + + value = HTML("<b>%s</b>") % ("<hello>",) + assert to_formatted_text(value) == [("class:b", "<hello>")] + + value = HTML("<b>%s</b><u>%s</u>") % ("<hello>", "</world>") + assert to_formatted_text(value) == [("class:b", "<hello>"), ("class:u", "</world>")] + + # Format function. + value = HTML("<b>{0}</b><u>{1}</u>").format("'hello'", '"world"') + assert to_formatted_text(value) == [("class:b", "'hello'"), ("class:u", '"world"')] + + value = HTML("<b>{a}</b><u>{b}</u>").format(a="hello", b="world") + assert to_formatted_text(value) == [("class:b", "hello"), ("class:u", "world")] + + value = HTML("<b>{:02d}</b><u>{:.3f}</u>").format(3, 3.14159) + assert to_formatted_text(value) == [("class:b", "03"), ("class:u", "3.142")] + + +def test_merge_formatted_text(): + html1 = HTML("<u>hello</u>") + html2 = HTML("<b>world</b>") + result = merge_formatted_text([html1, html2]) + + assert to_formatted_text(result) == [ + ("class:u", "hello"), + ("class:b", "world"), + ] + + +def test_pygments_tokens(): + text = [ + (("A", "B"), "hello"), # Token.A.B + (("C", "D", "E"), "hello"), # Token.C.D.E + ((), "world"), # Token + ] + + assert to_formatted_text(PygmentsTokens(text)) == [ + ("class:pygments.a.b", "hello"), + ("class:pygments.c.d.e", "hello"), + ("class:pygments", "world"), + ] + + +def test_split_lines(): + lines = list(split_lines([("class:a", "line1\nline2\nline3")])) + + assert lines == [ + [("class:a", "line1")], + [("class:a", "line2")], + [("class:a", "line3")], + ] + + +def test_split_lines_2(): + lines = list( + split_lines([("class:a", "line1"), ("class:b", "line2\nline3\nline4")]) + ) + + assert lines == [ + [("class:a", "line1"), ("class:b", "line2")], + [("class:b", "line3")], + [("class:b", "line4")], + ] + + +def test_split_lines_3(): + "Edge cases: inputs ending with newlines." + # -1- + lines = list(split_lines([("class:a", "line1\nline2\n")])) + + assert lines == [ + [("class:a", "line1")], + [("class:a", "line2")], + [("class:a", "")], + ] + + # -2- + lines = list(split_lines([("class:a", "\n")])) + + assert lines == [ + [], + [("class:a", "")], + ] + + # -3- + lines = list(split_lines([("class:a", "")])) + + assert lines == [ + [("class:a", "")], + ] diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..500b7f1 --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from asyncio import run + +from prompt_toolkit.history import FileHistory, InMemoryHistory, ThreadedHistory + + +def _call_history_load(history): + """ + Helper: Call the history "load" method and return the result as a list of strings. + """ + result = [] + + async def call_load(): + async for item in history.load(): + result.append(item) + + run(call_load()) + return result + + +def test_in_memory_history(): + history = InMemoryHistory() + history.append_string("hello") + history.append_string("world") + + # Newest should yield first. + assert _call_history_load(history) == ["world", "hello"] + + # Test another call. + assert _call_history_load(history) == ["world", "hello"] + + history.append_string("test3") + assert _call_history_load(history) == ["test3", "world", "hello"] + + # Passing history as a parameter. + history2 = InMemoryHistory(["abc", "def"]) + assert _call_history_load(history2) == ["def", "abc"] + + +def test_file_history(tmpdir): + histfile = tmpdir.join("history") + + history = FileHistory(histfile) + + history.append_string("hello") + history.append_string("world") + + # Newest should yield first. + assert _call_history_load(history) == ["world", "hello"] + + # Test another call. + assert _call_history_load(history) == ["world", "hello"] + + history.append_string("test3") + assert _call_history_load(history) == ["test3", "world", "hello"] + + # Create another history instance pointing to the same file. + history2 = FileHistory(histfile) + assert _call_history_load(history2) == ["test3", "world", "hello"] + + +def test_threaded_file_history(tmpdir): + histfile = tmpdir.join("history") + + history = ThreadedHistory(FileHistory(histfile)) + + history.append_string("hello") + history.append_string("world") + + # Newest should yield first. + assert _call_history_load(history) == ["world", "hello"] + + # Test another call. + assert _call_history_load(history) == ["world", "hello"] + + history.append_string("test3") + assert _call_history_load(history) == ["test3", "world", "hello"] + + # Create another history instance pointing to the same file. + history2 = ThreadedHistory(FileHistory(histfile)) + assert _call_history_load(history2) == ["test3", "world", "hello"] + + +def test_threaded_in_memory_history(): + # Threaded in memory history is not useful. But testing it anyway, just to + # see whether everything plays nicely together. + history = ThreadedHistory(InMemoryHistory()) + history.append_string("hello") + history.append_string("world") + + # Newest should yield first. + assert _call_history_load(history) == ["world", "hello"] + + # Test another call. + assert _call_history_load(history) == ["world", "hello"] + + history.append_string("test3") + assert _call_history_load(history) == ["test3", "world", "hello"] + + # Passing history as a parameter. + history2 = ThreadedHistory(InMemoryHistory(["abc", "def"])) + assert _call_history_load(history2) == ["def", "abc"] diff --git a/tests/test_inputstream.py b/tests/test_inputstream.py new file mode 100644 index 0000000..ab1b036 --- /dev/null +++ b/tests/test_inputstream.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import pytest + +from prompt_toolkit.input.vt100_parser import Vt100Parser +from prompt_toolkit.keys import Keys + + +class _ProcessorMock: + def __init__(self): + self.keys = [] + + def feed_key(self, key_press): + self.keys.append(key_press) + + +@pytest.fixture +def processor(): + return _ProcessorMock() + + +@pytest.fixture +def stream(processor): + return Vt100Parser(processor.feed_key) + + +def test_control_keys(processor, stream): + stream.feed("\x01\x02\x10") + + assert len(processor.keys) == 3 + assert processor.keys[0].key == Keys.ControlA + assert processor.keys[1].key == Keys.ControlB + assert processor.keys[2].key == Keys.ControlP + assert processor.keys[0].data == "\x01" + assert processor.keys[1].data == "\x02" + assert processor.keys[2].data == "\x10" + + +def test_arrows(processor, stream): + stream.feed("\x1b[A\x1b[B\x1b[C\x1b[D") + + assert len(processor.keys) == 4 + assert processor.keys[0].key == Keys.Up + assert processor.keys[1].key == Keys.Down + assert processor.keys[2].key == Keys.Right + assert processor.keys[3].key == Keys.Left + assert processor.keys[0].data == "\x1b[A" + assert processor.keys[1].data == "\x1b[B" + assert processor.keys[2].data == "\x1b[C" + assert processor.keys[3].data == "\x1b[D" + + +def test_escape(processor, stream): + stream.feed("\x1bhello") + + assert len(processor.keys) == 1 + len("hello") + assert processor.keys[0].key == Keys.Escape + assert processor.keys[1].key == "h" + assert processor.keys[0].data == "\x1b" + assert processor.keys[1].data == "h" + + +def test_special_double_keys(processor, stream): + stream.feed("\x1b[1;3D") # Should both send escape and left. + + assert len(processor.keys) == 2 + assert processor.keys[0].key == Keys.Escape + assert processor.keys[1].key == Keys.Left + assert processor.keys[0].data == "\x1b[1;3D" + assert processor.keys[1].data == "" + + +def test_flush_1(processor, stream): + # Send left key in two parts without flush. + stream.feed("\x1b") + stream.feed("[D") + + assert len(processor.keys) == 1 + assert processor.keys[0].key == Keys.Left + assert processor.keys[0].data == "\x1b[D" + + +def test_flush_2(processor, stream): + # Send left key with a 'Flush' in between. + # The flush should make sure that we process everything before as-is, + # with makes the first part just an escape character instead. + stream.feed("\x1b") + stream.flush() + stream.feed("[D") + + assert len(processor.keys) == 3 + assert processor.keys[0].key == Keys.Escape + assert processor.keys[1].key == "[" + assert processor.keys[2].key == "D" + + assert processor.keys[0].data == "\x1b" + assert processor.keys[1].data == "[" + assert processor.keys[2].data == "D" + + +def test_meta_arrows(processor, stream): + stream.feed("\x1b\x1b[D") + + assert len(processor.keys) == 2 + assert processor.keys[0].key == Keys.Escape + assert processor.keys[1].key == Keys.Left + + +def test_control_square_close(processor, stream): + stream.feed("\x1dC") + + assert len(processor.keys) == 2 + assert processor.keys[0].key == Keys.ControlSquareClose + assert processor.keys[1].key == "C" + + +def test_invalid(processor, stream): + # Invalid sequence that has at two characters in common with other + # sequences. + stream.feed("\x1b[*") + + assert len(processor.keys) == 3 + assert processor.keys[0].key == Keys.Escape + assert processor.keys[1].key == "[" + assert processor.keys[2].key == "*" + + +def test_cpr_response(processor, stream): + stream.feed("a\x1b[40;10Rb") + assert len(processor.keys) == 3 + assert processor.keys[0].key == "a" + assert processor.keys[1].key == Keys.CPRResponse + assert processor.keys[2].key == "b" + + +def test_cpr_response_2(processor, stream): + # Make sure that the newline is not included in the CPR response. + stream.feed("\x1b[40;1R\n") + assert len(processor.keys) == 2 + assert processor.keys[0].key == Keys.CPRResponse + assert processor.keys[1].key == Keys.ControlJ diff --git a/tests/test_key_binding.py b/tests/test_key_binding.py new file mode 100644 index 0000000..1c60880 --- /dev/null +++ b/tests/test_key_binding.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +from contextlib import contextmanager + +import pytest + +from prompt_toolkit.application import Application +from prompt_toolkit.application.current import set_app +from prompt_toolkit.input.defaults import create_pipe_input +from prompt_toolkit.key_binding.key_bindings import KeyBindings +from prompt_toolkit.key_binding.key_processor import KeyPress, KeyProcessor +from prompt_toolkit.keys import Keys +from prompt_toolkit.layout import Layout, Window +from prompt_toolkit.output import DummyOutput + + +class Handlers: + def __init__(self): + self.called = [] + + def __getattr__(self, name): + def func(event): + self.called.append(name) + + return func + + +@contextmanager +def set_dummy_app(): + """ + Return a context manager that makes sure that this dummy application is + active. This is important, because we need an `Application` with + `is_done=False` flag, otherwise no keys will be processed. + """ + with create_pipe_input() as pipe_input: + app = Application( + layout=Layout(Window()), + output=DummyOutput(), + input=pipe_input, + ) + + # Don't start background tasks for these tests. The `KeyProcessor` + # wants to create a background task for flushing keys. We can ignore it + # here for these tests. + # This patch is not clean. In the future, when we can use Taskgroups, + # the `Application` should pass its task group to the constructor of + # `KeyProcessor`. That way, it doesn't have to do a lookup using + # `get_app()`. + app.create_background_task = lambda *_, **kw: None + + with set_app(app): + yield + + +@pytest.fixture +def handlers(): + return Handlers() + + +@pytest.fixture +def bindings(handlers): + bindings = KeyBindings() + bindings.add(Keys.ControlX, Keys.ControlC)(handlers.controlx_controlc) + bindings.add(Keys.ControlX)(handlers.control_x) + bindings.add(Keys.ControlD)(handlers.control_d) + bindings.add(Keys.ControlSquareClose, Keys.Any)(handlers.control_square_close_any) + + return bindings + + +@pytest.fixture +def processor(bindings): + return KeyProcessor(bindings) + + +def test_remove_bindings(handlers): + with set_dummy_app(): + h = handlers.controlx_controlc + h2 = handlers.controld + + # Test passing a handler to the remove() function. + bindings = KeyBindings() + bindings.add(Keys.ControlX, Keys.ControlC)(h) + bindings.add(Keys.ControlD)(h2) + assert len(bindings.bindings) == 2 + bindings.remove(h) + assert len(bindings.bindings) == 1 + + # Test passing a key sequence to the remove() function. + bindings = KeyBindings() + bindings.add(Keys.ControlX, Keys.ControlC)(h) + bindings.add(Keys.ControlD)(h2) + assert len(bindings.bindings) == 2 + bindings.remove(Keys.ControlX, Keys.ControlC) + assert len(bindings.bindings) == 1 + + +def test_feed_simple(processor, handlers): + with set_dummy_app(): + processor.feed(KeyPress(Keys.ControlX, "\x18")) + processor.feed(KeyPress(Keys.ControlC, "\x03")) + processor.process_keys() + + assert handlers.called == ["controlx_controlc"] + + +def test_feed_several(processor, handlers): + with set_dummy_app(): + # First an unknown key first. + processor.feed(KeyPress(Keys.ControlQ, "")) + processor.process_keys() + + assert handlers.called == [] + + # Followed by a know key sequence. + processor.feed(KeyPress(Keys.ControlX, "")) + processor.feed(KeyPress(Keys.ControlC, "")) + processor.process_keys() + + assert handlers.called == ["controlx_controlc"] + + # Followed by another unknown sequence. + processor.feed(KeyPress(Keys.ControlR, "")) + processor.feed(KeyPress(Keys.ControlS, "")) + + # Followed again by a know key sequence. + processor.feed(KeyPress(Keys.ControlD, "")) + processor.process_keys() + + assert handlers.called == ["controlx_controlc", "control_d"] + + +def test_control_square_closed_any(processor, handlers): + with set_dummy_app(): + processor.feed(KeyPress(Keys.ControlSquareClose, "")) + processor.feed(KeyPress("C", "C")) + processor.process_keys() + + assert handlers.called == ["control_square_close_any"] + + +def test_common_prefix(processor, handlers): + with set_dummy_app(): + # Sending Control_X should not yet do anything, because there is + # another sequence starting with that as well. + processor.feed(KeyPress(Keys.ControlX, "")) + processor.process_keys() + + assert handlers.called == [] + + # When another key is pressed, we know that we did not meant the longer + # "ControlX ControlC" sequence and the callbacks are called. + processor.feed(KeyPress(Keys.ControlD, "")) + processor.process_keys() + + assert handlers.called == ["control_x", "control_d"] + + +def test_previous_key_sequence(processor): + """ + test whether we receive the correct previous_key_sequence. + """ + with set_dummy_app(): + events = [] + + def handler(event): + events.append(event) + + # Build registry. + registry = KeyBindings() + registry.add("a", "a")(handler) + registry.add("b", "b")(handler) + processor = KeyProcessor(registry) + + # Create processor and feed keys. + processor.feed(KeyPress("a", "a")) + processor.feed(KeyPress("a", "a")) + processor.feed(KeyPress("b", "b")) + processor.feed(KeyPress("b", "b")) + processor.process_keys() + + # Test. + assert len(events) == 2 + assert len(events[0].key_sequence) == 2 + assert events[0].key_sequence[0].key == "a" + assert events[0].key_sequence[0].data == "a" + assert events[0].key_sequence[1].key == "a" + assert events[0].key_sequence[1].data == "a" + assert events[0].previous_key_sequence == [] + + assert len(events[1].key_sequence) == 2 + assert events[1].key_sequence[0].key == "b" + assert events[1].key_sequence[0].data == "b" + assert events[1].key_sequence[1].key == "b" + assert events[1].key_sequence[1].data == "b" + assert len(events[1].previous_key_sequence) == 2 + assert events[1].previous_key_sequence[0].key == "a" + assert events[1].previous_key_sequence[0].data == "a" + assert events[1].previous_key_sequence[1].key == "a" + assert events[1].previous_key_sequence[1].data == "a" diff --git a/tests/test_layout.py b/tests/test_layout.py new file mode 100644 index 0000000..cbbbcd0 --- /dev/null +++ b/tests/test_layout.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import pytest + +from prompt_toolkit.layout import InvalidLayoutError, Layout +from prompt_toolkit.layout.containers import HSplit, VSplit, Window +from prompt_toolkit.layout.controls import BufferControl + + +def test_layout_class(): + c1 = BufferControl() + c2 = BufferControl() + c3 = BufferControl() + win1 = Window(content=c1) + win2 = Window(content=c2) + win3 = Window(content=c3) + + layout = Layout(container=VSplit([HSplit([win1, win2]), win3])) + + # Listing of windows/controls. + assert list(layout.find_all_windows()) == [win1, win2, win3] + assert list(layout.find_all_controls()) == [c1, c2, c3] + + # Focusing something. + layout.focus(c1) + assert layout.has_focus(c1) + assert layout.has_focus(win1) + assert layout.current_control == c1 + assert layout.previous_control == c1 + + layout.focus(c2) + assert layout.has_focus(c2) + assert layout.has_focus(win2) + assert layout.current_control == c2 + assert layout.previous_control == c1 + + layout.focus(win3) + assert layout.has_focus(c3) + assert layout.has_focus(win3) + assert layout.current_control == c3 + assert layout.previous_control == c2 + + # Pop focus. This should focus the previous control again. + layout.focus_last() + assert layout.has_focus(c2) + assert layout.has_focus(win2) + assert layout.current_control == c2 + assert layout.previous_control == c1 + + +def test_create_invalid_layout(): + with pytest.raises(InvalidLayoutError): + Layout(HSplit([])) diff --git a/tests/test_memory_leaks.py b/tests/test_memory_leaks.py new file mode 100644 index 0000000..31ea7c8 --- /dev/null +++ b/tests/test_memory_leaks.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import gc + +import pytest + +from prompt_toolkit.shortcuts.prompt import PromptSession + + +def _count_prompt_session_instances() -> int: + # Run full GC collection first. + gc.collect() + + # Count number of remaining referenced `PromptSession` instances. + objects = gc.get_objects() + return len([obj for obj in objects if isinstance(obj, PromptSession)]) + + +# Fails in GitHub CI, probably due to GC differences. +@pytest.mark.xfail(reason="Memory leak testing fails in GitHub CI.") +def test_prompt_session_memory_leak() -> None: + before_count = _count_prompt_session_instances() + + # Somehow in CI/CD, the before_count is > 0 + assert before_count == 0 + + p = PromptSession() + + after_count = _count_prompt_session_instances() + assert after_count == before_count + 1 + + del p + + after_delete_count = _count_prompt_session_instances() + assert after_delete_count == before_count diff --git a/tests/test_print_formatted_text.py b/tests/test_print_formatted_text.py new file mode 100644 index 0000000..26c7265 --- /dev/null +++ b/tests/test_print_formatted_text.py @@ -0,0 +1,92 @@ +""" +Test the `print` function. +""" +from __future__ import annotations + +import pytest + +from prompt_toolkit import print_formatted_text as pt_print +from prompt_toolkit.formatted_text import HTML, FormattedText, to_formatted_text +from prompt_toolkit.output import ColorDepth +from prompt_toolkit.styles import Style +from prompt_toolkit.utils import is_windows + + +class _Capture: + "Emulate an stdout object." + + def __init__(self): + self._data = [] + + def write(self, data): + self._data.append(data) + + @property + def data(self): + return "".join(self._data) + + def flush(self): + pass + + def isatty(self): + return True + + def fileno(self): + # File descriptor is not used for printing formatted text. + # (It is only needed for getting the terminal size.) + return -1 + + +@pytest.mark.skipif(is_windows(), reason="Doesn't run on Windows yet.") +def test_print_formatted_text(): + f = _Capture() + pt_print([("", "hello"), ("", "world")], file=f) + assert "hello" in f.data + assert "world" in f.data + + +@pytest.mark.skipif(is_windows(), reason="Doesn't run on Windows yet.") +def test_print_formatted_text_backslash_r(): + f = _Capture() + pt_print("hello\r\n", file=f) + assert "hello" in f.data + + +@pytest.mark.skipif(is_windows(), reason="Doesn't run on Windows yet.") +def test_formatted_text_with_style(): + f = _Capture() + style = Style.from_dict( + { + "hello": "#ff0066", + "world": "#44ff44 italic", + } + ) + tokens = FormattedText( + [ + ("class:hello", "Hello "), + ("class:world", "world"), + ] + ) + + # NOTE: We pass the default (8bit) color depth, so that the unit tests + # don't start failing when environment variables change. + pt_print(tokens, style=style, file=f, color_depth=ColorDepth.DEFAULT) + assert "\x1b[0;38;5;197mHello" in f.data + assert "\x1b[0;38;5;83;3mworld" in f.data + + +@pytest.mark.skipif(is_windows(), reason="Doesn't run on Windows yet.") +def test_html_with_style(): + """ + Text `print_formatted_text` with `HTML` wrapped in `to_formatted_text`. + """ + f = _Capture() + + html = HTML("<ansigreen>hello</ansigreen> <b>world</b>") + formatted_text = to_formatted_text(html, style="class:myhtml") + pt_print(formatted_text, file=f, color_depth=ColorDepth.DEFAULT) + + assert ( + f.data + == "\x1b[0m\x1b[?7h\x1b[0;32mhello\x1b[0m \x1b[0;1mworld\x1b[0m\r\n\x1b[0m" + ) diff --git a/tests/test_regular_languages.py b/tests/test_regular_languages.py new file mode 100644 index 0000000..deef6b8 --- /dev/null +++ b/tests/test_regular_languages.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from prompt_toolkit.completion import CompleteEvent, Completer, Completion +from prompt_toolkit.contrib.regular_languages import compile +from prompt_toolkit.contrib.regular_languages.compiler import Match, Variables +from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter +from prompt_toolkit.document import Document + + +def test_simple_match(): + g = compile("hello|world") + + m = g.match("hello") + assert isinstance(m, Match) + + m = g.match("world") + assert isinstance(m, Match) + + m = g.match("somethingelse") + assert m is None + + +def test_variable_varname(): + """ + Test `Variable` with varname. + """ + g = compile("((?P<varname>hello|world)|test)") + + m = g.match("hello") + variables = m.variables() + assert isinstance(variables, Variables) + assert variables.get("varname") == "hello" + assert variables["varname"] == "hello" + + m = g.match("world") + variables = m.variables() + assert isinstance(variables, Variables) + assert variables.get("varname") == "world" + assert variables["varname"] == "world" + + m = g.match("test") + variables = m.variables() + assert isinstance(variables, Variables) + assert variables.get("varname") is None + assert variables["varname"] is None + + +def test_prefix(): + """ + Test `match_prefix`. + """ + g = compile(r"(hello\ world|something\ else)") + + m = g.match_prefix("hello world") + assert isinstance(m, Match) + + m = g.match_prefix("he") + assert isinstance(m, Match) + + m = g.match_prefix("") + assert isinstance(m, Match) + + m = g.match_prefix("som") + assert isinstance(m, Match) + + m = g.match_prefix("hello wor") + assert isinstance(m, Match) + + m = g.match_prefix("no-match") + assert m.trailing_input().start == 0 + assert m.trailing_input().stop == len("no-match") + + m = g.match_prefix("hellotest") + assert m.trailing_input().start == len("hello") + assert m.trailing_input().stop == len("hellotest") + + +def test_completer(): + class completer1(Completer): + def get_completions(self, document, complete_event): + yield Completion("before-%s-after" % document.text, -len(document.text)) + yield Completion("before-%s-after-B" % document.text, -len(document.text)) + + class completer2(Completer): + def get_completions(self, document, complete_event): + yield Completion("before2-%s-after2" % document.text, -len(document.text)) + yield Completion("before2-%s-after2-B" % document.text, -len(document.text)) + + # Create grammar. "var1" + "whitespace" + "var2" + g = compile(r"(?P<var1>[a-z]*) \s+ (?P<var2>[a-z]*)") + + # Test 'get_completions()' + completer = GrammarCompleter(g, {"var1": completer1(), "var2": completer2()}) + completions = list( + completer.get_completions(Document("abc def", len("abc def")), CompleteEvent()) + ) + + assert len(completions) == 2 + assert completions[0].text == "before2-def-after2" + assert completions[0].start_position == -3 + assert completions[1].text == "before2-def-after2-B" + assert completions[1].start_position == -3 diff --git a/tests/test_shortcuts.py b/tests/test_shortcuts.py new file mode 100644 index 0000000..287c6d3 --- /dev/null +++ b/tests/test_shortcuts.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from prompt_toolkit.shortcuts import print_container +from prompt_toolkit.shortcuts.prompt import _split_multiline_prompt +from prompt_toolkit.widgets import Frame, TextArea + + +def test_split_multiline_prompt(): + # Test 1: no newlines: + tokens = [("class:testclass", "ab")] + has_before_tokens, before, first_input_line = _split_multiline_prompt( + lambda: tokens + ) + assert has_before_tokens() is False + assert before() == [] + assert first_input_line() == [ + ("class:testclass", "a"), + ("class:testclass", "b"), + ] + + # Test 1: multiple lines. + tokens = [("class:testclass", "ab\ncd\nef")] + has_before_tokens, before, first_input_line = _split_multiline_prompt( + lambda: tokens + ) + assert has_before_tokens() is True + assert before() == [ + ("class:testclass", "a"), + ("class:testclass", "b"), + ("class:testclass", "\n"), + ("class:testclass", "c"), + ("class:testclass", "d"), + ] + assert first_input_line() == [ + ("class:testclass", "e"), + ("class:testclass", "f"), + ] + + # Edge case 1: starting with a newline. + tokens = [("class:testclass", "\nab")] + has_before_tokens, before, first_input_line = _split_multiline_prompt( + lambda: tokens + ) + assert has_before_tokens() is True + assert before() == [] + assert first_input_line() == [("class:testclass", "a"), ("class:testclass", "b")] + + # Edge case 2: starting with two newlines. + tokens = [("class:testclass", "\n\nab")] + has_before_tokens, before, first_input_line = _split_multiline_prompt( + lambda: tokens + ) + assert has_before_tokens() is True + assert before() == [("class:testclass", "\n")] + assert first_input_line() == [("class:testclass", "a"), ("class:testclass", "b")] + + +def test_print_container(tmpdir): + # Call `print_container`, render to a dummy file. + f = tmpdir.join("output") + with open(f, "w") as fd: + print_container(Frame(TextArea(text="Hello world!\n"), title="Title"), file=fd) + + # Verify rendered output. + with open(f) as fd: + text = fd.read() + assert "Hello world" in text + assert "Title" in text diff --git a/tests/test_style.py b/tests/test_style.py new file mode 100644 index 0000000..d0a4790 --- /dev/null +++ b/tests/test_style.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +from prompt_toolkit.styles import Attrs, Style, SwapLightAndDarkStyleTransformation + + +def test_style_from_dict(): + style = Style.from_dict( + { + "a": "#ff0000 bold underline strike italic", + "b": "bg:#00ff00 blink reverse", + } + ) + + # Lookup of class:a. + expected = Attrs( + color="ff0000", + bgcolor="", + bold=True, + underline=True, + strike=True, + italic=True, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:a") == expected + + # Lookup of class:b. + expected = Attrs( + color="", + bgcolor="00ff00", + bold=False, + underline=False, + strike=False, + italic=False, + blink=True, + reverse=True, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:b") == expected + + # Test inline style. + expected = Attrs( + color="ff0000", + bgcolor="", + bold=False, + underline=False, + strike=False, + italic=False, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("#ff0000") == expected + + # Combine class name and inline style (Whatever is defined later gets priority.) + expected = Attrs( + color="00ff00", + bgcolor="", + bold=True, + underline=True, + strike=True, + italic=True, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:a #00ff00") == expected + + expected = Attrs( + color="ff0000", + bgcolor="", + bold=True, + underline=True, + strike=True, + italic=True, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("#00ff00 class:a") == expected + + +def test_class_combinations_1(): + # In this case, our style has both class 'a' and 'b'. + # Given that the style for 'a b' is defined at the end, that one is used. + style = Style( + [ + ("a", "#0000ff"), + ("b", "#00ff00"), + ("a b", "#ff0000"), + ] + ) + expected = Attrs( + color="ff0000", + bgcolor="", + bold=False, + underline=False, + strike=False, + italic=False, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:a class:b") == expected + assert style.get_attrs_for_style_str("class:a,b") == expected + assert style.get_attrs_for_style_str("class:a,b,c") == expected + + # Changing the order shouldn't matter. + assert style.get_attrs_for_style_str("class:b class:a") == expected + assert style.get_attrs_for_style_str("class:b,a") == expected + + +def test_class_combinations_2(): + # In this case, our style has both class 'a' and 'b'. + # The style that is defined the latest get priority. + style = Style( + [ + ("a b", "#ff0000"), + ("b", "#00ff00"), + ("a", "#0000ff"), + ] + ) + expected = Attrs( + color="00ff00", + bgcolor="", + bold=False, + underline=False, + strike=False, + italic=False, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:a class:b") == expected + assert style.get_attrs_for_style_str("class:a,b") == expected + assert style.get_attrs_for_style_str("class:a,b,c") == expected + + # Defining 'a' latest should give priority to 'a'. + expected = Attrs( + color="0000ff", + bgcolor="", + bold=False, + underline=False, + strike=False, + italic=False, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:b class:a") == expected + assert style.get_attrs_for_style_str("class:b,a") == expected + + +def test_substyles(): + style = Style( + [ + ("a.b", "#ff0000 bold"), + ("a", "#0000ff"), + ("b", "#00ff00"), + ("b.c", "#0000ff italic"), + ] + ) + + # Starting with a.* + expected = Attrs( + color="0000ff", + bgcolor="", + bold=False, + underline=False, + strike=False, + italic=False, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:a") == expected + + expected = Attrs( + color="ff0000", + bgcolor="", + bold=True, + underline=False, + strike=False, + italic=False, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:a.b") == expected + assert style.get_attrs_for_style_str("class:a.b.c") == expected + + # Starting with b.* + expected = Attrs( + color="00ff00", + bgcolor="", + bold=False, + underline=False, + strike=False, + italic=False, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:b") == expected + assert style.get_attrs_for_style_str("class:b.a") == expected + + expected = Attrs( + color="0000ff", + bgcolor="", + bold=False, + underline=False, + strike=False, + italic=True, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:b.c") == expected + assert style.get_attrs_for_style_str("class:b.c.d") == expected + + +def test_swap_light_and_dark_style_transformation(): + transformation = SwapLightAndDarkStyleTransformation() + + # Test with 6 digit hex colors. + before = Attrs( + color="440000", + bgcolor="888844", + bold=True, + underline=True, + strike=True, + italic=True, + blink=False, + reverse=False, + hidden=False, + ) + after = Attrs( + color="ffbbbb", + bgcolor="bbbb76", + bold=True, + underline=True, + strike=True, + italic=True, + blink=False, + reverse=False, + hidden=False, + ) + + assert transformation.transform_attrs(before) == after + + # Test with ANSI colors. + before = Attrs( + color="ansired", + bgcolor="ansiblack", + bold=True, + underline=True, + strike=True, + italic=True, + blink=False, + reverse=False, + hidden=False, + ) + after = Attrs( + color="ansibrightred", + bgcolor="ansiwhite", + bold=True, + underline=True, + strike=True, + italic=True, + blink=False, + reverse=False, + hidden=False, + ) + + assert transformation.transform_attrs(before) == after diff --git a/tests/test_style_transformation.py b/tests/test_style_transformation.py new file mode 100644 index 0000000..e4eee7c --- /dev/null +++ b/tests/test_style_transformation.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import pytest + +from prompt_toolkit.styles import AdjustBrightnessStyleTransformation, Attrs + + +@pytest.fixture +def default_attrs(): + return Attrs( + color="", + bgcolor="", + bold=False, + underline=False, + strike=False, + italic=False, + blink=False, + reverse=False, + hidden=False, + ) + + +def test_adjust_brightness_style_transformation(default_attrs): + tr = AdjustBrightnessStyleTransformation(0.5, 1.0) + + attrs = tr.transform_attrs(default_attrs._replace(color="ff0000")) + assert attrs.color == "ff7f7f" + + attrs = tr.transform_attrs(default_attrs._replace(color="00ffaa")) + assert attrs.color == "7fffd4" + + # When a background color is given, nothing should change. + attrs = tr.transform_attrs(default_attrs._replace(color="00ffaa", bgcolor="white")) + assert attrs.color == "00ffaa" + + # Test ansi colors. + attrs = tr.transform_attrs(default_attrs._replace(color="ansiblue")) + assert attrs.color == "6666ff" + + # Test 'ansidefault'. This shouldn't change. + attrs = tr.transform_attrs(default_attrs._replace(color="ansidefault")) + assert attrs.color == "ansidefault" + + # When 0 and 1 are given, don't do any style transformation. + tr2 = AdjustBrightnessStyleTransformation(0, 1) + + attrs = tr2.transform_attrs(default_attrs._replace(color="ansiblue")) + assert attrs.color == "ansiblue" + + attrs = tr2.transform_attrs(default_attrs._replace(color="00ffaa")) + assert attrs.color == "00ffaa" diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..9d4c808 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import itertools + +import pytest + +from prompt_toolkit.utils import take_using_weights + + +def test_using_weights(): + def take(generator, count): + return list(itertools.islice(generator, 0, count)) + + # Check distribution. + data = take(take_using_weights(["A", "B", "C"], [5, 10, 20]), 35) + assert data.count("A") == 5 + assert data.count("B") == 10 + assert data.count("C") == 20 + + assert data == [ + "A", + "B", + "C", + "C", + "B", + "C", + "C", + "A", + "B", + "C", + "C", + "B", + "C", + "C", + "A", + "B", + "C", + "C", + "B", + "C", + "C", + "A", + "B", + "C", + "C", + "B", + "C", + "C", + "A", + "B", + "C", + "C", + "B", + "C", + "C", + ] + + # Another order. + data = take(take_using_weights(["A", "B", "C"], [20, 10, 5]), 35) + assert data.count("A") == 20 + assert data.count("B") == 10 + assert data.count("C") == 5 + + # Bigger numbers. + data = take(take_using_weights(["A", "B", "C"], [20, 10, 5]), 70) + assert data.count("A") == 40 + assert data.count("B") == 20 + assert data.count("C") == 10 + + # Negative numbers. + data = take(take_using_weights(["A", "B", "C"], [-20, 10, 0]), 70) + assert data.count("A") == 0 + assert data.count("B") == 70 + assert data.count("C") == 0 + + # All zero-weight items. + with pytest.raises(ValueError): + take(take_using_weights(["A", "B", "C"], [0, 0, 0]), 70) diff --git a/tests/test_vt100_output.py b/tests/test_vt100_output.py new file mode 100644 index 0000000..65c8377 --- /dev/null +++ b/tests/test_vt100_output.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from prompt_toolkit.output.vt100 import _get_closest_ansi_color + + +def test_get_closest_ansi_color(): + # White + assert _get_closest_ansi_color(255, 255, 255) == "ansiwhite" + assert _get_closest_ansi_color(250, 250, 250) == "ansiwhite" + + # Black + assert _get_closest_ansi_color(0, 0, 0) == "ansiblack" + assert _get_closest_ansi_color(5, 5, 5) == "ansiblack" + + # Green + assert _get_closest_ansi_color(0, 255, 0) == "ansibrightgreen" + assert _get_closest_ansi_color(10, 255, 0) == "ansibrightgreen" + assert _get_closest_ansi_color(0, 255, 10) == "ansibrightgreen" + + assert _get_closest_ansi_color(220, 220, 100) == "ansiyellow" diff --git a/tests/test_widgets.py b/tests/test_widgets.py new file mode 100644 index 0000000..ee7745a --- /dev/null +++ b/tests/test_widgets.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from prompt_toolkit.formatted_text import fragment_list_to_text +from prompt_toolkit.layout import to_window +from prompt_toolkit.widgets import Button + + +def _to_text(button: Button) -> str: + control = to_window(button).content + return fragment_list_to_text(control.text()) + + +def test_default_button(): + button = Button("Exit") + assert _to_text(button) == "< Exit >" + + +def test_custom_button(): + button = Button("Exit", left_symbol="[", right_symbol="]") + assert _to_text(button) == "[ Exit ]" diff --git a/tests/test_yank_nth_arg.py b/tests/test_yank_nth_arg.py new file mode 100644 index 0000000..7167a26 --- /dev/null +++ b/tests/test_yank_nth_arg.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import pytest + +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.history import InMemoryHistory + + +@pytest.fixture +def _history(): + "Prefilled history." + history = InMemoryHistory() + history.append_string("alpha beta gamma delta") + history.append_string("one two three four") + return history + + +# Test yank_last_arg. + + +def test_empty_history(): + buf = Buffer() + buf.yank_last_arg() + assert buf.document.current_line == "" + + +def test_simple_search(_history): + buff = Buffer(history=_history) + buff.yank_last_arg() + assert buff.document.current_line == "four" + + +def test_simple_search_with_quotes(_history): + _history.append_string("""one two "three 'x' four"\n""") + buff = Buffer(history=_history) + buff.yank_last_arg() + assert buff.document.current_line == '''"three 'x' four"''' + + +def test_simple_search_with_arg(_history): + buff = Buffer(history=_history) + buff.yank_last_arg(n=2) + assert buff.document.current_line == "three" + + +def test_simple_search_with_arg_out_of_bounds(_history): + buff = Buffer(history=_history) + buff.yank_last_arg(n=8) + assert buff.document.current_line == "" + + +def test_repeated_search(_history): + buff = Buffer(history=_history) + buff.yank_last_arg() + buff.yank_last_arg() + assert buff.document.current_line == "delta" + + +def test_repeated_search_with_wraparound(_history): + buff = Buffer(history=_history) + buff.yank_last_arg() + buff.yank_last_arg() + buff.yank_last_arg() + assert buff.document.current_line == "four" + + +# Test yank_last_arg. + + +def test_yank_nth_arg(_history): + buff = Buffer(history=_history) + buff.yank_nth_arg() + assert buff.document.current_line == "two" + + +def test_repeated_yank_nth_arg(_history): + buff = Buffer(history=_history) + buff.yank_nth_arg() + buff.yank_nth_arg() + assert buff.document.current_line == "beta" + + +def test_yank_nth_arg_with_arg(_history): + buff = Buffer(history=_history) + buff.yank_nth_arg(n=2) + assert buff.document.current_line == "three" |