207eddc922
pyte 0.8.2 does not handle CSI S (SU — Scroll Up) or CSI T (SD — Scroll Down). When TERM=xterm-256color, tmux sends CSI n S for bulk scrolling instead of DECSTBM+index. pyte silently ignored these sequences, leaving old content in the screen buffer — visible as ghost content in SVG screenshots. Fix: monkeypatch pyte's CSI dispatch tables to map S→scroll_up and T→scroll_down, and implement both methods on AltScreen with proper scroll-region (DECSTBM) support. Adds 6 tests for SU/SD functionality.
302 lines
11 KiB
Python
302 lines
11 KiB
Python
"""Tests for the AltScreen class with alternate screen buffer support."""
|
|
|
|
import pyte
|
|
import pytest
|
|
|
|
from webterm.alt_screen import DECALTBUF, DECALTBUF_1047, DECALTBUF_1048, AltScreen
|
|
|
|
|
|
class TestAltScreen:
|
|
"""Tests for AltScreen alternate buffer support."""
|
|
|
|
def test_basic_screen_operations(self):
|
|
"""Test that basic screen operations still work."""
|
|
screen = AltScreen(40, 10)
|
|
stream = pyte.Stream(screen)
|
|
|
|
stream.feed("Hello World\r\n")
|
|
stream.feed("Line 2")
|
|
|
|
assert "Hello World" in screen.display[0]
|
|
assert "Line 2" in screen.display[1]
|
|
|
|
def test_alternate_screen_save_restore(self):
|
|
"""Test DECSET/DECRST 1049 saves and restores main screen."""
|
|
screen = AltScreen(40, 10)
|
|
stream = pyte.Stream(screen)
|
|
|
|
# Write to main screen
|
|
stream.feed("MAIN SCREEN LINE 1\r\n")
|
|
stream.feed("MAIN SCREEN LINE 2\r\n")
|
|
assert "MAIN SCREEN LINE 1" in screen.display[0]
|
|
assert "MAIN SCREEN LINE 2" in screen.display[1]
|
|
|
|
# Enter alternate screen (DECSET 1049)
|
|
stream.feed("\x1b[?1049h")
|
|
# Screen should be cleared
|
|
assert screen.display[0].strip() == ""
|
|
assert screen.display[1].strip() == ""
|
|
|
|
# Write to alternate screen
|
|
stream.feed("ALT SCREEN CONTENT\r\n")
|
|
assert "ALT SCREEN CONTENT" in screen.display[0]
|
|
|
|
# Exit alternate screen (DECRST 1049)
|
|
stream.feed("\x1b[?1049l")
|
|
# Main screen should be restored
|
|
assert "MAIN SCREEN LINE 1" in screen.display[0]
|
|
assert "MAIN SCREEN LINE 2" in screen.display[1]
|
|
|
|
def test_alternate_screen_mode_flag(self):
|
|
"""Test that DECALTBUF mode flag is set correctly."""
|
|
screen = AltScreen(40, 10)
|
|
stream = pyte.Stream(screen)
|
|
|
|
assert DECALTBUF not in screen.mode
|
|
|
|
stream.feed("\x1b[?1049h")
|
|
assert DECALTBUF in screen.mode
|
|
|
|
stream.feed("\x1b[?1049l")
|
|
assert DECALTBUF not in screen.mode
|
|
|
|
@pytest.mark.parametrize(
|
|
("enter_seq", "exit_seq", "mode_flag"),
|
|
[
|
|
("\x1b[?1047h", "\x1b[?1047l", DECALTBUF_1047),
|
|
("\x1b[?1048h", "\x1b[?1048l", DECALTBUF_1048),
|
|
],
|
|
)
|
|
def test_alternate_screen_mode_variants(self, enter_seq, exit_seq, mode_flag):
|
|
"""Test that DECSET/DECRST 1047/1048 trigger alternate buffer handling."""
|
|
screen = AltScreen(40, 10)
|
|
stream = pyte.Stream(screen)
|
|
|
|
stream.feed("MAIN SCREEN\r\n")
|
|
assert "MAIN SCREEN" in screen.display[0]
|
|
|
|
stream.feed(enter_seq)
|
|
assert mode_flag in screen.mode
|
|
assert screen.display[0].strip() == ""
|
|
|
|
stream.feed("ALT BUFFER\r\n")
|
|
assert "ALT BUFFER" in screen.display[0]
|
|
|
|
stream.feed(exit_seq)
|
|
assert mode_flag not in screen.mode
|
|
assert "MAIN SCREEN" in screen.display[0]
|
|
|
|
def test_multiple_alt_screen_switches(self):
|
|
"""Test multiple switches between main and alternate screen."""
|
|
screen = AltScreen(40, 10)
|
|
stream = pyte.Stream(screen)
|
|
|
|
# Main content
|
|
stream.feed("MAIN 1\r\n")
|
|
stream.feed("\x1b[?1049h") # Enter alt
|
|
stream.feed("ALT 1\r\n")
|
|
stream.feed("\x1b[?1049l") # Exit alt
|
|
assert "MAIN 1" in screen.display[0]
|
|
|
|
# More main content
|
|
stream.feed("MAIN 2\r\n")
|
|
stream.feed("\x1b[?1049h") # Enter alt again
|
|
assert screen.display[0].strip() == "" # Alt screen is clear
|
|
stream.feed("\x1b[?1049l") # Exit alt
|
|
assert "MAIN 1" in screen.display[0]
|
|
assert "MAIN 2" in screen.display[1]
|
|
|
|
def test_resize_invalidates_saved_buffer(self):
|
|
"""Test that resizing clears the saved alternate screen buffer."""
|
|
screen = AltScreen(40, 10)
|
|
stream = pyte.Stream(screen)
|
|
|
|
stream.feed("MAIN CONTENT\r\n")
|
|
stream.feed("\x1b[?1049h") # Enter alt
|
|
assert screen._saved_buffer is not None
|
|
|
|
# Resize while in alt mode
|
|
screen.resize(20, 80)
|
|
assert screen._saved_buffer is None
|
|
|
|
def test_ed_clear_still_works(self):
|
|
"""Test that explicit ED (erase display) still works."""
|
|
screen = AltScreen(40, 10)
|
|
stream = pyte.Stream(screen)
|
|
|
|
stream.feed("Line 1\r\n")
|
|
stream.feed("Line 2\r\n")
|
|
stream.feed("\x1b[2J") # ED 2 - erase entire display
|
|
|
|
assert all(line.strip() == "" for line in screen.display)
|
|
|
|
|
|
class TestExpandClearSequences:
|
|
"""Tests for expand_clear_sequences (Ink partial clear fix)."""
|
|
|
|
def test_no_clear_sequences(self):
|
|
"""Data without EL2+CUU1 runs is returned unchanged."""
|
|
screen = AltScreen(80, 24)
|
|
data = b"Hello world\r\n"
|
|
assert screen.expand_clear_sequences(data) == data
|
|
|
|
def test_short_clear_not_expanded(self):
|
|
"""Runs of fewer than 3 EL2+CUU1 pairs are not modified."""
|
|
screen = AltScreen(80, 24)
|
|
data = b"\x1b[2K\x1b[1A\x1b[2K\x1b[1A" # 2 pairs
|
|
assert screen.expand_clear_sequences(data) == data
|
|
|
|
def test_full_clear_not_expanded(self):
|
|
"""A clear that already reaches row 0 is not extended."""
|
|
screen = AltScreen(80, 24)
|
|
stream = pyte.ByteStream(screen)
|
|
# Put cursor at row 5
|
|
stream.feed(b"\r\n" * 5)
|
|
assert screen.cursor.y == 5
|
|
|
|
# 5-pair clear already covers rows 5 down to 0
|
|
data = b"\x1b[2K\x1b[1A" * 5
|
|
result = screen.expand_clear_sequences(data)
|
|
assert result == data
|
|
|
|
def test_partial_clear_is_extended(self):
|
|
"""A partial clear that doesn't reach row 0 gets extended."""
|
|
screen = AltScreen(80, 24)
|
|
stream = pyte.ByteStream(screen)
|
|
# Draw content to push cursor to row 20
|
|
for i in range(20):
|
|
stream.feed(f"Line {i}\r\n".encode())
|
|
assert screen.cursor.y == 20
|
|
|
|
# Only clear 5 lines (should extend to clear all 20)
|
|
data = b"\x1b[2K\x1b[1A" * 5
|
|
result = screen.expand_clear_sequences(data)
|
|
expected_pairs = 20 # extend from 5 to 20
|
|
assert result.count(b"\x1b[2K\x1b[1A") == expected_pairs
|
|
|
|
def test_partial_clear_produces_correct_screen(self):
|
|
"""Simulates Ink /clear: partial clear + redraw leaves clean screen."""
|
|
screen = AltScreen(80, 24)
|
|
stream = pyte.ByteStream(screen)
|
|
|
|
# Draw 15 lines of content (Ink frame 1)
|
|
for i in range(15):
|
|
stream.feed(f"Old line {i}\r\n".encode())
|
|
|
|
# Ink /clear: only clears 5 lines then redraws fresh prompt
|
|
clear = b"\x1b[2K\x1b[1A" * 5 + b"\x1b[2K\x1b[G"
|
|
new_content = b"Fresh prompt\r\n"
|
|
|
|
expanded = screen.expand_clear_sequences(clear)
|
|
stream.feed(expanded)
|
|
stream.feed(new_content)
|
|
|
|
# Old content should be gone
|
|
non_empty = [line.rstrip() for line in screen.display if line.strip()]
|
|
assert len(non_empty) == 1
|
|
assert non_empty[0] == "Fresh prompt"
|
|
|
|
def test_data_around_clear_preserved(self):
|
|
"""Text before and after a clear run is preserved."""
|
|
screen = AltScreen(80, 24)
|
|
stream = pyte.ByteStream(screen)
|
|
stream.feed(b"\r\n" * 10)
|
|
|
|
data = b"before\x1b[2K\x1b[1A" * 0 + b"before" + b"\x1b[2K\x1b[1A" * 5 + b"after"
|
|
result = screen.expand_clear_sequences(data)
|
|
assert result.startswith(b"before")
|
|
assert result.endswith(b"after")
|
|
|
|
|
|
class TestScrollUpDown:
|
|
"""Tests for CSI S (SU) and CSI T (SD) support."""
|
|
|
|
def test_scroll_up_basic(self):
|
|
"""CSI S scrolls content up, adding blank lines at bottom."""
|
|
screen = AltScreen(40, 10)
|
|
stream = pyte.ByteStream(screen)
|
|
for i in range(10):
|
|
stream.feed(f"Line {i}\r\n".encode())
|
|
|
|
# After writing, Line 0 already scrolled off; rows 0-8 have Lines 1-9
|
|
# Scroll up 3 more lines
|
|
stream.feed(b"\x1b[3S")
|
|
|
|
assert "Line 4" in screen.display[0]
|
|
assert "Line 9" in screen.display[5]
|
|
assert screen.display[6].strip() == ""
|
|
|
|
def test_scroll_up_default_one(self):
|
|
"""CSI S with no parameter defaults to 1 line."""
|
|
screen = AltScreen(40, 5)
|
|
stream = pyte.ByteStream(screen)
|
|
for i in range(5):
|
|
stream.feed(f"L{i}\r\n".encode())
|
|
|
|
stream.feed(b"\x1b[S")
|
|
assert "L1" in screen.display[0]
|
|
|
|
def test_scroll_down_basic(self):
|
|
"""CSI T scrolls content down, adding blank lines at top."""
|
|
screen = AltScreen(40, 10)
|
|
stream = pyte.ByteStream(screen)
|
|
for i in range(10):
|
|
stream.feed(f"Line {i}\r\n".encode())
|
|
|
|
stream.feed(b"\x1b[3T")
|
|
|
|
assert screen.display[0].strip() == ""
|
|
assert screen.display[2].strip() == ""
|
|
assert "Line 1" in screen.display[3]
|
|
|
|
def test_scroll_up_with_margins(self):
|
|
"""SU respects the scroll region set by DECSTBM."""
|
|
screen = AltScreen(40, 10)
|
|
stream = pyte.ByteStream(screen)
|
|
for i in range(10):
|
|
stream.feed(f"Row {i}\r\n".encode())
|
|
|
|
# After writing, rows 0-8 have Row 1..Row 9
|
|
# Set scroll region to rows 3-7 (1-based: 4;8)
|
|
stream.feed(b"\x1b[4;8r")
|
|
stream.feed(b"\x1b[2S")
|
|
|
|
# Rows outside the region should be unchanged
|
|
assert "Row 1" in screen.display[0]
|
|
assert "Row 2" in screen.display[1]
|
|
assert "Row 3" in screen.display[2]
|
|
# Rows inside the region shifted up by 2
|
|
assert "Row 6" in screen.display[3]
|
|
|
|
def test_scroll_up_clears_ghost_content(self):
|
|
"""Simulates tmux sending SU during Ink /clear — ghost content is eliminated."""
|
|
screen = AltScreen(80, 24)
|
|
stream = pyte.ByteStream(screen)
|
|
|
|
# Fill screen with "old" content
|
|
for i in range(24):
|
|
stream.feed(f"Old line {i}\r\n".encode())
|
|
|
|
non_empty_before = sum(1 for line in screen.display if line.strip())
|
|
assert non_empty_before > 15
|
|
|
|
# Simulate tmux clear: set margins, scroll up, reset margins
|
|
stream.feed(b"\x1b[1;23r") # Set scroll region
|
|
stream.feed(b"\x1b[20S") # Scroll up 20 lines
|
|
stream.feed(b"\x1b[r") # Reset scroll region
|
|
|
|
non_empty_after = sum(1 for line in screen.display if line.strip())
|
|
assert non_empty_after <= 5, f"Expected <= 5 non-empty lines, got {non_empty_after}"
|
|
|
|
def test_scroll_up_cursor_unchanged(self):
|
|
"""SU does not move the cursor position."""
|
|
screen = AltScreen(40, 10)
|
|
stream = pyte.ByteStream(screen)
|
|
stream.feed(b"\x1b[5;10H") # Move cursor to row 5, col 10
|
|
|
|
saved_y, saved_x = screen.cursor.y, screen.cursor.x
|
|
stream.feed(b"\x1b[3S")
|
|
|
|
assert screen.cursor.y == saved_y
|
|
assert screen.cursor.x == saved_x
|