"""Tests for InputHistory — command history buffer and line editing.""" from __future__ import annotations import io from collections import deque import pytest from h2g2.engine.input_history import InputHistory # Escape sequences for arrow keys UP = "\x1b[A" DOWN = "\x1b[B" RIGHT = "\x1b[C" LEFT = "\x1b[D" HOME = "\x1b[H" END = "\x1b[F" DELETE = "\x1b[3~" BACKSPACE = "\x7f" ENTER = "\r" CTRL_D = "\x04" CTRL_C = "\x03" class FakeInputHistory(InputHistory): """InputHistory subclass that reads from a character queue and captures terminal output instead of touching the real terminal.""" def __init__(self, chars: str = "") -> None: super().__init__() self._chars: deque[str] = deque(chars) self.output = io.StringIO() def feed(self, chars: str) -> None: self._chars.extend(chars) def _read_char(self, fd: int) -> str: return self._chars.popleft() def input(self, prompt: str = "> ") -> str: """Bypass terminal raw-mode setup; drive _read_line directly.""" self.output.write(prompt) line = self._read_line(0, prompt) if line: self._history.append(line) return line @staticmethod def _redraw(buf: list[str], cursor: int, prompt: str) -> None: # Suppress terminal escape codes during tests pass # --------------------------------------------------------------------------- # History buffer basics # --------------------------------------------------------------------------- class TestHistoryGrowsDynamically: def test_single_entry(self): h = FakeInputHistory("hello" + ENTER) assert h.input() == "hello" assert h._history == ["hello"] def test_multiple_entries(self): h = FakeInputHistory() for cmd in ["north", "take lamp", "inventory"]: h.feed(cmd + ENTER) h.input() assert h._history == ["north", "take lamp", "inventory"] def test_empty_input_not_stored(self): h = FakeInputHistory(ENTER) assert h.input() == "" assert h._history == [] def test_grows_unbounded(self): h = FakeInputHistory() for i in range(500): h.feed(f"cmd{i}" + ENTER) h.input() assert len(h._history) == 500 assert h._history[0] == "cmd0" assert h._history[499] == "cmd499" # --------------------------------------------------------------------------- # Up/Down arrow navigation # --------------------------------------------------------------------------- class TestUpDownNavigation: def _make_history(self, *commands: str) -> FakeInputHistory: h = FakeInputHistory() for cmd in commands: h.feed(cmd + ENTER) h.input() return h def test_up_recalls_last_command(self): h = self._make_history("look", "north") h.feed(UP + ENTER) assert h.input() == "north" def test_up_twice_recalls_older(self): h = self._make_history("look", "north", "take lamp") h.feed(UP + UP + ENTER) assert h.input() == "north" def test_up_then_down_returns_to_newer(self): h = self._make_history("look", "north") h.feed(UP + UP + DOWN + ENTER) assert h.input() == "north" def test_down_past_end_restores_current_input(self): h = self._make_history("look") # Type "hel", arrow up (recalls "look"), arrow down (restores "hel") h.feed("hel" + UP + DOWN + ENTER) assert h.input() == "hel" def test_up_at_top_stays_at_oldest(self): h = self._make_history("only") h.feed(UP + UP + UP + ENTER) assert h.input() == "only" def test_down_with_no_history_is_noop(self): h = FakeInputHistory(DOWN + "hello" + ENTER) assert h.input() == "hello" def test_up_with_no_history_is_noop(self): h = FakeInputHistory(UP + "hello" + ENTER) assert h.input() == "hello" def test_browse_full_history(self): h = self._make_history("a", "b", "c") # Go all the way up (c -> b -> a), then submit h.feed(UP + UP + UP + ENTER) assert h.input() == "a" def test_recalled_command_added_to_history(self): h = self._make_history("look") h.feed(UP + ENTER) result = h.input() assert result == "look" # "look" is now in history twice (original + re-submitted) assert h._history == ["look", "look"] # --------------------------------------------------------------------------- # Line editing # --------------------------------------------------------------------------- class TestLineEditing: def test_backspace_deletes_char(self): h = FakeInputHistory("helloo" + BACKSPACE + ENTER) assert h.input() == "hello" def test_backspace_at_start_is_noop(self): h = FakeInputHistory(BACKSPACE + "hi" + ENTER) assert h.input() == "hi" def test_left_arrow_and_insert(self): # Type "hllo", left 3 times to position after 'h', insert 'e' h = FakeInputHistory("hllo" + LEFT + LEFT + LEFT + "e" + ENTER) assert h.input() == "hello" def test_right_arrow(self): # Type "hllo", left 3, right 1, insert 'e' (between l and l) h = FakeInputHistory("hllo" + LEFT + LEFT + LEFT + RIGHT + "e" + ENTER) assert h.input() == "hlelo" def test_home_moves_to_start(self): h = FakeInputHistory("ello" + HOME + "h" + ENTER) assert h.input() == "hello" def test_end_moves_to_end(self): h = FakeInputHistory("hel" + HOME + END + "lo" + ENTER) assert h.input() == "hello" def test_delete_key(self): # Type "hello", left twice (cursor before 'l' 'o'), delete removes 'l' h = FakeInputHistory("hello" + LEFT + LEFT + DELETE + ENTER) assert h.input() == "helo" def test_delete_at_end_is_noop(self): h = FakeInputHistory("hi" + DELETE + ENTER) assert h.input() == "hi" def test_backspace_mid_line(self): # Type "helxlo", left 2 (before 'l' 'o'), backspace removes 'x'... # Actually: "helxlo" then LEFT LEFT puts cursor before "lo", backspace removes 'x' h = FakeInputHistory("helxlo" + LEFT + LEFT + BACKSPACE + ENTER) assert h.input() == "hello" # --------------------------------------------------------------------------- # Ctrl-D / Ctrl-C # --------------------------------------------------------------------------- class TestControlKeys: def test_ctrl_d_on_empty_raises_eof(self): h = FakeInputHistory(CTRL_D) with pytest.raises(EOFError): h.input() def test_ctrl_d_with_content_is_ignored(self): h = FakeInputHistory("hi" + CTRL_D + ENTER) assert h.input() == "hi" def test_ctrl_c_raises_keyboard_interrupt(self): h = FakeInputHistory(CTRL_C) with pytest.raises(KeyboardInterrupt): h.input()