initial import

This commit is contained in:
Vaclav Tvrdik
2026-08-27 14:23:44 +02:00
committed by Vaclav Tvrdik
commit 430af419c0
38 changed files with 6175 additions and 0 deletions

33
tests/_bootstrap.py Normal file
View File

@@ -0,0 +1,33 @@
""" sys.path setup and fakes shared by the test scripts
They run as plain scripts (see README), so each imports this first to get the
sublime stubs and the Package Control libraries on the path.
"""
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ST_LIB = os.path.expanduser(
"~/Library/Application Support/Sublime Text/Lib/python38")
for path in (ROOT, ST_LIB, os.path.join(ROOT, "tests", "stubs")):
if path not in sys.path:
sys.path.insert(0, path)
class FakeProcess:
"""Captures what the emulator writes to the pty, one entry per write."""
def __init__(self):
self.writes = []
def write(self, data):
self.writes.append(data)
@property
def written(self):
return "".join(self.writes)
@written.setter
def written(self, value):
self.writes = [value] if value else []

89
tests/harness_live.py Normal file
View File

@@ -0,0 +1,89 @@
""" Live harness: run a real command inside VtScreen/VtStream without Sublime.
Usage: python3 tests/harness_live.py <cmd> [seconds] [--send <text> | --type <text>]
--send writes the text in one go; --type writes a character per write, as typing does. """
import codecs
import os
import sys
import threading
import time
import _bootstrap # sets sys.path for the imports below
from vterm.emulator import VtScreen, VtStream
from vterm.terminal import TerminalPtyProcess, agent_env
cmd = sys.argv[1:2] or ["/bin/zsh"]
seconds = float(sys.argv[2]) if len(sys.argv) > 2 else 3.0
send = None
if "--send" in sys.argv:
send = sys.argv[sys.argv.index("--send") + 1]
typed = None
if "--type" in sys.argv:
typed = sys.argv[sys.argv.index("--type") + 1]
# the same process and environment the plugin spawns
proc = TerminalPtyProcess.spawn(
cmd, cwd=os.getcwd(), env=agent_env(), dimensions=(30, 100))
replies = []
class Screen(VtScreen):
"""Records the query replies the emulator writes back to the app."""
def write_process_input(self, data):
replies.append(data)
super().write_process_input(data)
screen = Screen(100, 30, process=proc)
stream = VtStream(screen)
decoder = codecs.getincrementaldecoder("utf-8")("replace")
lock = threading.Lock()
stop = [False]
def reader():
while not stop[0]:
try:
raw = proc.read(4096)
except EOFError:
break
with lock:
stream.feed(decoder.decode(raw))
t = threading.Thread(target=reader, daemon=True)
t.start()
if send is not None:
time.sleep(2)
proc.write(send)
if typed is not None:
time.sleep(2)
# one write per character: the app frames its input per read, so a whole
# string in one write is not the same test as typing it
for char in typed:
proc.write(char)
time.sleep(0.04)
time.sleep(seconds)
stop[0] = True
try:
proc.terminate(force=True)
except Exception:
pass
print("=== queries answered ===")
for r in replies:
print(repr(r))
print("=== screen (non-empty rows) ===")
for y in range(screen.lines):
row = "".join(screen.buffer[y][x].data for x in range(screen.columns)).rstrip()
if row:
print("{:2}| {}".format(y, row))
print("=== modes: {} title: {!r} alt: {}".format(
sorted(m >> 5 for m in screen.mode if m >= 32), screen.title,
screen.alternate_buffer_mode))

89
tests/stubs/sublime.py Normal file
View File

@@ -0,0 +1,89 @@
""" Minimal sublime API stub for offline tests """
class Region:
def __init__(self, a, b=None):
self.a = a
self.b = a if b is None else b
def begin(self):
return min(self.a, self.b)
def end(self):
return max(self.a, self.b)
def empty(self):
return self.a == self.b
OP_EQUAL = 0
OP_NOT_EQUAL = 1
_settings = {}
_clipboard = [""]
_timeouts = []
class Settings(dict):
def get(self, key, default=None):
return dict.get(self, key, default)
def set(self, key, value):
self[key] = value
def erase(self, key):
self.pop(key, None)
def has(self, key):
return key in self
def add_on_change(self, tag, callback):
pass
def clear_on_change(self, tag):
pass
def load_settings(name):
return _settings.setdefault(name, Settings())
_saved = []
def save_settings(name):
_saved.append(name)
def set_timeout(fn, delay=0):
_timeouts.append(fn)
def set_timeout_async(fn, delay=0):
_timeouts.append(fn)
def run_timeouts():
while _timeouts:
_timeouts.pop(0)()
def set_clipboard(text):
_clipboard[0] = text
def get_clipboard():
return _clipboard[0]
def packages_path():
import tempfile
return tempfile.gettempdir()
def expand_variables(value, variables):
return value
def version():
return "4200"

View File

@@ -0,0 +1,15 @@
""" Minimal sublime_plugin stub """
class TextCommand:
def __init__(self, view=None):
self.view = view
class WindowCommand:
def __init__(self, window=None):
self.window = window
class EventListener:
pass

126
tests/test_commands.py Normal file
View File

@@ -0,0 +1,126 @@
""" Theme picker flows: preview, cancel, confirm.
Run with: python3 tests/test_commands.py """
import _bootstrap # sets sys.path for the imports below
from _bootstrap import FakeProcess
import sublime
from vterm.commands import VtermSelectThemeCommand
from vterm.utils import SETTINGS_FILE
from vterm.themes import THEMES
settings = sublime.load_settings(SETTINGS_FILE)
panel = {}
class FakeWindow:
def show_quick_panel(self, items, on_done, selected_index=-1, on_highlight=None):
panel.update(items=items, on_done=on_done,
selected_index=selected_index, on_highlight=on_highlight)
def open_picker(current):
settings.set("theme", current)
command = VtermSelectThemeCommand(FakeWindow())
command.run()
return command
# every theme is offered, with "no theme" first
command = open_picker(None)
assert [i[0] for i in panel["items"]] == ["No theme"] + sorted(THEMES), panel["items"]
assert panel["selected_index"] == 0
# the configured theme starts highlighted
command = open_picker("pro")
assert panel["selected_index"] == command.choices.index("pro")
# highlighting previews but must not persist
saves = len(sublime._saved)
panel["on_highlight"](command.choices.index("slate"))
assert settings.get("theme") == "slate", settings.get("theme")
assert len(sublime._saved) == saves, "preview must not save settings"
# cancelling restores the value from before the panel opened
panel["on_done"](-1)
assert settings.get("theme") == "pro", settings.get("theme")
# confirming persists
command = open_picker("pro")
panel["on_done"](command.choices.index("homebrew"))
assert settings.get("theme") == "homebrew", settings.get("theme")
assert sublime._saved[-1] == SETTINGS_FILE
# "no theme" persists as null rather than a name
command = open_picker("slate")
panel["on_done"](0)
assert settings.get("theme") is None, settings.get("theme")
print("theme picker tests passed ({} themes offered)".format(len(THEMES)))
# a palette change must reach terminals that are already running: the screen
# answers OSC 10/11 and DSR 996 from these values
from vterm.emulator import VtScreen, VtStream
from vterm.scheme import scheme_manager
from vterm.terminal import Terminal
recorder = FakeProcess()
# real __init__ (view unused here); process/screen stand in for start()
terminal = Terminal(None)
terminal.screen = VtScreen(20, 3, process=recorder, fg_color="000000",
bg_color="ffffff", is_dark=False)
terminal.process = recorder
stream = VtStream(terminal.screen)
scheme_manager.foreground = "#eeeeee"
scheme_manager.background = "#111111"
terminal.apply_scheme()
assert terminal.screen.default_bg_color == "111111", terminal.screen.default_bg_color
assert terminal.screen.theme_is_dark is True
# without mode 2031 the app is not interrupted
assert recorder.written == "", repr(recorder.written)
# with it, the dark/light report is sent so the agent can restyle itself
stream.feed("\x1b[?2031h")
recorder.written = ""
terminal.apply_scheme()
assert recorder.written == "\x1b[?997;1n", repr(recorder.written)
# and the emulator answers a colour query with the new palette
recorder.written = ""
stream.feed("\x1b]11;?\x07")
assert recorder.written == "\x1b]11;rgb:1111/1111/1111\x07", repr(recorder.written)
print("live theme tests passed (running screen adopts palette, app notified)")
# the scheme file must exist before any restored tab's color_scheme is loaded at
# startup, and building twice must not duplicate the seeded rules
import os
from vterm.scheme import SCHEME_FILE_NAME
path = os.path.join(scheme_manager._directory(), SCHEME_FILE_NAME)
if os.path.exists(path):
os.remove(path)
scheme_manager.ensure_file()
assert os.path.exists(path), "ensure_file did not write the scheme"
assert not scheme_manager._initialized, \
"ensure_file must leave the palette open to refinement by initialize()"
# a second call is a no-op: same file, untouched
stamp = os.path.getmtime(path)
scheme_manager.ensure_file()
assert os.path.getmtime(path) == stamp, "ensure_file rewrote an existing scheme"
# _build is idempotent, which is what makes the ensure_file + initialize pair safe
with scheme_manager.lock:
scheme_manager._build()
first = len(scheme_manager.rules)
scheme_manager._build()
assert len(scheme_manager.rules) == first, \
"rules duplicated: {} -> {}".format(first, len(scheme_manager.rules))
print("scheme file tests passed ({} seeded rules, idempotent)".format(first))

420
tests/test_emulator.py Normal file
View File

@@ -0,0 +1,420 @@
""" Offline emulator tests: queries answered, modes tracked, parsing survives
chunk boundaries. Run with: python3 tests/test_emulator.py """
import _bootstrap # sets sys.path for the imports below
from _bootstrap import FakeProcess
from vterm.emulator import VtScreen, VtStream
def make(columns=80, lines=24):
proc = FakeProcess()
screen = VtScreen(columns, lines, process=proc)
stream = VtStream(screen)
return proc, screen, stream
def feed(stream, data, char_by_char=False):
if char_by_char:
for c in data:
stream.feed(c)
else:
stream.feed(data)
def run_case(char_by_char):
tag = "char-by-char" if char_by_char else "chunked"
# DA1 answered (Claude Code's untimed sentinel)
proc, screen, stream = make()
feed(stream, "\x1b[c", char_by_char)
assert proc.written == "\x1b[?62;22c", (tag, proc.written)
# CPR, plain and private form
proc, screen, stream = make()
feed(stream, "\x1b[5;10H\x1b[6n\x1b[?6n", char_by_char)
assert proc.written == "\x1b[5;10R\x1b[?5;10R", (tag, proc.written)
# XTVERSION then DECRQM 2026 (the Claude Code probe sequence)
proc, screen, stream = make()
feed(stream, "\x1b[>0q\x1b[c\x1b[?2026$p\x1b[c", char_by_char)
assert proc.written == (
"\x1bP>|vterm 0.1\x1b\\" "\x1b[?62;22c"
"\x1b[?2026;2$y" "\x1b[?62;22c"), (tag, proc.written)
# DECRQM for DECAWM (7) and DECTCEM (25): both default-on, and the
# wire-number list must answer them as set, not as unrecognized
proc, screen, stream = make()
feed(stream, "\x1b[?7$p\x1b[?25$p\x1b[?2026$p", char_by_char)
assert proc.written == "\x1b[?7;1$y\x1b[?25;1$y\x1b[?2026;2$y", (tag, proc.written)
# kitty probe: push ignored, query answered 0, DA1 fence answered
proc, screen, stream = make()
feed(stream, "\x1b[>7u\x1b[?u\x1b[c\x1b[<u", char_by_char)
assert proc.written == "\x1b[?0u\x1b[?62;22c", (tag, proc.written)
# and nothing leaked to the screen
assert screen.buffer[0][0].data == " ", (tag, screen.buffer[0][0])
# modifyOtherKeys must not corrupt SGR state
proc, screen, stream = make()
feed(stream, "\x1b[>4;2mhello", char_by_char)
assert screen.buffer[0][0].data == "h" and not screen.buffer[0][0].underscore, tag
# OSC 11 color query answered; OSC 0 sets title; OSC 133 dropped
proc, screen, stream = make()
screen.default_bg_color = "1a2b3c"
feed(stream, "\x1b]0;mytitle\x07\x1b]11;?\x07\x1b]133;A\x07x", char_by_char)
assert screen.title == "mytitle", (tag, screen.title)
assert proc.written == "\x1b]11;rgb:1a1a/2b2b/3c3c\x07", (tag, proc.written)
assert screen.buffer[0][0].data == "x", tag
# OSC with ST terminator; a query asked with ST is answered with ST (meli)
proc, screen, stream = make()
screen.default_bg_color = "1a2b3c"
feed(stream, "\x1b]2;st-title\x1b\\\x1b]11;?\x1b\\y", char_by_char)
assert screen.title == "st-title", (tag, screen.title)
assert proc.written == "\x1b]11;rgb:1a1a/2b2b/3c3c\x1b\\", (tag, proc.written)
assert screen.buffer[0][0].data == "y", tag
# OSC 52 clipboard (base64 "hi")
got = []
proc = FakeProcess()
screen = VtScreen(80, 24, process=proc, clipboard_callback=got.append)
stream = VtStream(screen)
feed(stream, "\x1b]52;c;aGk=\x07", char_by_char)
assert got == ["hi"], (tag, got)
# bracketed paste and sync modes tracked
proc, screen, stream = make()
feed(stream, "\x1b[?2004h\x1b[?2026h", char_by_char)
assert screen.bracketed_paste_enabled(), tag
assert screen.in_sync_frame(), tag
feed(stream, "\x1b[?2026l\x1b[?2004l", char_by_char)
assert not screen.in_sync_frame(), tag
assert not screen.bracketed_paste_enabled(), tag
# mouse modes
proc, screen, stream = make()
feed(stream, "\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h", char_by_char)
assert screen.mouse_reporting_enabled(), tag
assert screen.focus_tracking_enabled(), tag
# alt screen switch and restore
proc, screen, stream = make()
feed(stream, "primary\x1b[?1049h\x1b[2Jalt\x1b[?1049l", char_by_char)
row0 = "".join(screen.buffer[0][i].data for i in range(7))
assert row0 == "primary", (tag, row0)
assert not screen.alternate_buffer_mode, tag
# truecolor SGR is quantized into the 256 palette; 256-color as hex; bold tracked
proc, screen, stream = make()
feed(stream, "\x1b[1;38;2;255;0;10mX\x1b[0m\x1b[38;5;196mY", char_by_char)
assert screen.buffer[0][0].fg == "ff0000" and screen.buffer[0][0].bold, tag
assert screen.buffer[0][1].fg == "ff0000", (tag, screen.buffer[0][1].fg)
# SGR 5/25 (blink): ignored, not a crash — Char has no blink field
proc, screen, stream = make()
feed(stream, "\x1b[5;31mX\x1b[25mY", char_by_char)
assert screen.buffer[0][0].fg == "red", (tag, screen.buffer[0][0])
assert screen.buffer[0][1].fg == "red" and screen.buffer[0][1].data == "Y", tag
# DECSTBM + SU/SD don't crash and scroll within margins
proc, screen, stream = make(10, 6)
feed(stream, "\x1b[2;5r\x1b[2S\x1b[1T\x1b[r", char_by_char)
assert screen.margins is None, tag
# DECSCUSR ignored, CSI t ignored, ?996n answered
proc, screen, stream = make()
feed(stream, "\x1b[2 q\x1b[16t\x1b[?996n", char_by_char)
assert proc.written == "\x1b[?997;1n", (tag, proc.written)
# scrollback: lines pushed to history when scrolling at bottom
proc, screen, stream = make(10, 3)
feed(stream, "a\r\nb\r\nc\r\nd", char_by_char)
assert len(screen.history) == 1, (tag, len(screen.history))
assert screen.history[0][0].data == "a", tag
# ED 3 clears history and fires callback
cleared = []
proc = FakeProcess()
screen = VtScreen(10, 3, process=proc, clear_callback=lambda: cleared.append(1))
stream = VtStream(screen)
feed(stream, "a\r\nb\r\nc\r\nd\x1b[3J", char_by_char)
assert len(screen.history) == 0 and cleared, tag
# DCS/APC payloads are consumed, not drawn
proc, screen, stream = make()
feed(stream, "\x1bP1$rpayload\x1b\\Z\x1b_Gapc\x1b\\W", char_by_char)
row0 = "".join(screen.buffer[0][i].data for i in range(2))
assert row0 == "ZW", (tag, row0)
# DEC special graphics: xterm-256color's smacs is ESC(0, so ncurses apps
# (mutt, dialog, less) draw borders with it when the locale is not UTF-8
proc, screen, stream = make(20, 3)
feed(stream, "\x1b(0lqqqk\x1b(Bplain", char_by_char)
row = "".join(screen.buffer[0][x].data for x in range(10))
assert row == "\u250c\u2500\u2500\u2500\u2510plain", (tag, repr(row))
# G1 designation plus SO/SI stays ignored in UTF-8 mode, so a stray 0x0e in
# decoded text cannot silently switch the charset
proc, screen, stream = make(20, 3)
feed(stream, "\x1b)0\x0eq\x0f", char_by_char)
assert screen.buffer[0][0].data == "q", (tag, screen.buffer[0][0].data)
# multi-byte text is unaffected by a graphics designation
proc, screen, stream = make(20, 3)
feed(stream, "\x1b(0\u00e9\u2500", char_by_char)
row = "".join(screen.buffer[0][x].data for x in range(2))
assert row == "\u00e9\u2500", (tag, repr(row))
# shift+enter esc-cr passthrough sanity: ESC CR fed back in draws nothing odd
proc, screen, stream = make()
feed(stream, "\x1b[?25l\x1b[?25h", char_by_char)
assert not screen.cursor.hidden, tag
# DECSCUSR tracked; missing parameter means 0; reset restores the default
proc, screen, stream = make()
feed(stream, "\x1b[2 q", char_by_char)
assert screen.cursor_style == 2, (tag, screen.cursor_style)
feed(stream, "\x1b[ q", char_by_char)
assert screen.cursor_style == 0, (tag, screen.cursor_style)
feed(stream, "\x1b[6 q", char_by_char)
screen.reset()
assert screen.cursor_style == 0, (tag, screen.cursor_style)
# nothing leaked to the screen or the pty
assert screen.buffer[0][0].data == " " and proc.written == "", tag
print("all emulator tests passed ({})".format(tag))
def run_resize_cases():
"""Shrinking must drop cells past the new width, in both buffers: the
segmenter walks a line to its highest key, so leftovers keep rendering."""
proc, screen, stream = make(20, 3)
feed(stream, "ABCDEFGHIJKLMNOPQRST", False)
screen.resize(3, 10)
assert max(screen.buffer[0]) < 10, sorted(screen.buffer[0])
# the buffer parked by the alt screen is trimmed too, or switching back
# brings the stale cells with it
proc, screen, stream = make(20, 3)
feed(stream, "PRIMARYPRIMARYPRIMAR", False)
feed(stream, "\x1b[?1049h", False)
screen.resize(3, 8)
feed(stream, "\x1b[?1049l", False)
assert max(screen.buffer[0]) < 8, sorted(screen.buffer[0])
# mode 2031 is how a live theme change reaches the app
proc, screen, stream = make()
assert not screen.theme_notify_enabled()
feed(stream, "\x1b[?2031h", False)
assert screen.theme_notify_enabled()
print("all resize tests passed")
def run_private_csi_cases():
"""No private CSI may raise: an exception here used to kill the renderer
thread and freeze the tab. aerc probes XTSMGRAPHICS (ESC[?2;1;0S)."""
for final in "ABCDEFGHJKLMPSTXZbcdfghlmnqrstu@":
proc, screen, stream = make()
stream.feed("\x1b[?2;1;0" + final) # multi-parameter private CSI
stream.feed("\x1b[?1" + final) # single-parameter
stream.feed("\x1b[?" + final) # bare
# the ones we do support still work
proc, screen, stream = make()
feed(stream, "\x1b[?1000h\x1b[?1002h\x1b[?1006h", False)
assert screen.mouse_reporting_enabled(), "private h must still set modes"
feed(stream, "\x1b[?1000l\x1b[?1002l", False)
assert not screen.mouse_reporting_enabled(), "private l must still reset modes"
proc, screen, stream = make()
feed(stream, "\x1b[5;10H\x1b[?6n", False)
assert proc.written == "\x1b[?5;10R", proc.written
# ...and an unsupported one is ignored rather than answered or dispatched
proc, screen, stream = make()
feed(stream, "\x1b[?2;1;0S", False)
assert proc.written == "", "XTSMGRAPHICS must be ignored, not answered"
print("all private CSI tests passed")
def run_decscnm_cases():
"""A DECSCNM flash (vim's visual bell) must not outlive itself.
pyte points every line at the blank cell from inside set/reset_mode, so a
line kept a reverse default and painted the columns the app never wrote —
vim skips indentation with cursor-forward — as white blocks.
"""
from vterm.render import segment_buffer_line
# a gutter, then the indent skipped with CSI 8C, then the text: vim's shape
paint = "\x1b[1;1H\x1b[93m 47 \x1b[m\x1b[8C\x1b[96mreturn\x1b[m default"
for order in (paint + "\x1b[?5h\x1b[?5l", "\x1b[?5h\x1b[?5l" + paint):
proc, screen, stream = make(columns=60, lines=5)
feed(stream, order, False)
assert not screen.default_char.reverse, "the flash is over"
assert not screen.buffer[0].default.reverse, "line kept the flash's blank cell"
assert not screen.buffer[0][6].reverse, "untouched column left reverse"
painted = [s for s in segment_buffer_line(screen.buffer[0])
if not s[0].strip() and s[3] == "reverse_default"]
assert not painted, ("whitespace painted reverse", painted)
# while the flash is on, the untouched columns invert too
proc, screen, stream = make(columns=60, lines=5)
feed(stream, paint + "\x1b[?5h", False)
assert screen.buffer[0].default.reverse and screen.buffer[0][6].reverse, \
"DECSCNM must invert the whole screen, not only the written columns"
print("all DECSCNM tests passed")
# everything the two paths must agree on: text, SGR in each form, empty and
# clamped params, cursor moves, private modes, prefixed CSI, DECRQM, OSC,
# scroll regions, controls inside a CSI, a colon subparameter, wide chars
GAUNTLET = (
"plain text\r\n"
"\x1b[1;31mbold red\x1b[m normal \x1b[38;5;190mpalette\x1b[0m\r\n"
"\x1b[38;2;10;200;30mtruecolor\x1b[49m\x1b[m\r\n"
"\x1b[H\x1b[2Kover\twritten\x1b[3;5Hmoved\x1b[999999C clamp\x1b[m\r\n"
"\x1b[?25l\x1b[?1049h\x1b[?25h\x1b[?1049l"
"\x1b[>q\x1b[?2026$p\x1b[5n\x1b[6n"
"\x1b]0;a title\x07\x1b]11;?\x07"
"\x1b[2;5r\x1b[Sscrolled\x1b[T\x1b[r"
"\x1b[38:5:100mcolon\x1b[m\r\n"
"\x1b[2\x073m bel mid-CSI \x1b[3\x18X can cancels\r\n"
"wide: 漢字 \x1b[7m漢\x1b[m\r\n"
"\x1b[m\x1b[;5m\x1b[5;m\x1b[25m params\r\n"
)
def run_fast_path_cases():
"""The feed() fast path must be indistinguishable from the parser."""
def state(screen):
rows = [
tuple(screen.buffer[y][x] for x in range(screen.columns))
for y in range(screen.lines)
]
c = screen.cursor
return (rows, c.x, c.y, c.attrs, set(screen.mode), screen.title)
def parser_only(blob):
"""The blob through the coroutine parser alone: pyte's original feed."""
proc, screen, stream = make(columns=30, lines=8)
import pyte
pyte.Stream.feed(stream, blob)
return proc, screen
ref_proc, ref_screen = parser_only(GAUNTLET)
reference = state(ref_screen)
# chunk sizes that split every sequence somewhere; 1 exercises pure carry
for size in (1, 2, 3, 7, 64, len(GAUNTLET)):
proc, screen, stream = make(columns=30, lines=8)
for i in range(0, len(GAUNTLET), size):
stream.feed(GAUNTLET[i : i + size])
assert state(screen) == reference, "fast path diverged at chunk size {}".format(size)
assert proc.written == ref_proc.written, (size, proc.written, ref_proc.written)
# a param run past the carry bound still parses, via the parser
proc, screen, stream = make()
stream.feed("\x1b[" + "1;" * 40)
stream.feed("31mX")
assert screen.buffer[0][0].fg == "red", screen.buffer[0][0]
# a carried partial does not linger: nothing dispatches until it completes
proc, screen, stream = make()
stream.feed("A\x1b[3")
assert screen.buffer[0][1].data == " ", "partial CSI must not be drawn"
stream.feed("1mB")
assert screen.buffer[0][1].fg == "red" and screen.buffer[0][1].data == "B"
print("all fast-path tests passed")
def run_malformed_csi_cases():
"""A malformed CSI must cost one sequence, not the chunk.
Excess params used to TypeError out of the dispatch; feed_data caught it
and dropped up to 256K of output. Triggered by cat'ing a binary file or a
corrupted stream. The SGR range guards the blink class the same way.
"""
for char_by_char in (False, True): # fast path / parser
for final in (chr(c) for c in range(0x40, 0x7F)):
for params in ("2;3", "0;0;0"):
proc, screen, stream = make(columns=20, lines=6)
feed(stream, "ab\x1b[{}{}cd".format(params, final), char_by_char)
row = "".join(screen.buffer[y][x].data for y in range(6) for x in range(20))
assert "c" in row and "d" in row, \
("output after a malformed CSI was lost", params, final, char_by_char)
for n in range(121):
proc, screen, stream = make(columns=20, lines=6)
feed(stream, "a\x1b[{}mb".format(n), char_by_char)
assert screen.buffer[0][1].data == "b", ("SGR {} ate the chunk".format(n), char_by_char)
print("all malformed-CSI tests passed")
def run_clear_cases():
"""Screen wipe and post-clear history suppression (vterm_clear_screen)."""
proc, screen, stream = make(10, 4)
feed(stream, "aaa\r\nbbb\r\nccc\r\nddd\r\neee", False)
assert len(screen.history) > 0
assert screen.buffer[0][0].data != " "
screen.wipe()
rows = ["".join(screen.buffer[y][x].data for x in range(screen.columns)).strip()
for y in range(screen.lines)]
assert rows == ["", "", "", ""], rows
assert (screen.cursor.x, screen.cursor.y) == (0, 0)
assert screen.dirty == set(range(4)), screen.dirty
# the app's repaint right after a clear must not refill scrollback
screen.history.clear()
feed(stream, "one\r\ntwo\r\nthree\r\nfour\r\nfive", False)
assert len(screen.history) == 0, len(screen.history)
# ... but scrollback works again once the window expires
screen._suppress_history_until = 0
feed(stream, "\r\nsix\r\nseven", False)
assert len(screen.history) > 0, "history suppressed for too long"
# ED 3 is app-driven: it clears scrollback and fires the clear callback,
# and (as in pyte/Terminus) also blanks the screen. Both agents pair it
# with 2J and repaint, so the render side must not wipe or nudge again.
cleared = []
proc = FakeProcess()
screen = VtScreen(10, 4, process=proc, clear_callback=lambda: cleared.append(1))
stream = VtStream(screen)
feed(stream, "gone\r\nalso-gone\r\nmore\r\nlines\r\nx", False)
assert len(screen.history) > 0
feed(stream, "\x1b[3J", False)
assert len(screen.history) == 0 and cleared, (len(screen.history), cleared)
# with erase_scrollback off, ED 3 still blanks the screen but the history
# and the view survive: agents repaint with it and would take both
cleared = []
proc = FakeProcess()
screen = VtScreen(10, 4, process=proc, erase_scrollback=False,
clear_callback=lambda: cleared.append(1))
stream = VtStream(screen)
feed(stream, "kept\r\nalso-kept\r\nmore\r\nlines\r\nx", False)
kept = len(screen.history)
assert kept > 0
del cleared[:] # the screen's own reset() fired one at construction
feed(stream, "\x1b[3J", False)
assert len(screen.history) == kept and not cleared, (len(screen.history), cleared)
assert screen.buffer[0][0].data == " ", screen.buffer[0][0]
print("all clear-screen tests passed")
run_case(char_by_char=False)
run_case(char_by_char=True)
run_clear_cases()
run_resize_cases()
run_private_csi_cases()
run_decscnm_cases()
run_fast_path_cases()
run_malformed_csi_cases()

125
tests/test_imports.py Normal file
View File

@@ -0,0 +1,125 @@
""" Smoke test: every plugin module imports cleanly against the sublime stubs.
Run with: python3 tests/test_imports.py """
import _bootstrap # sets sys.path for the imports below
ROOT = _bootstrap.ROOT
import vterm.utils
import vterm.keys
import vterm.emulator
import vterm.scheme
import vterm.terminal
import vterm.render
import vterm.commands
import vterm.listeners
import vterm.mouse
# keymap and mousemap must parse the way sublime parses them: JSON with
# // line comments
import importlib
import json
import os
import re
import sys
import types
def load_sublime_json(name):
with open(os.path.join(ROOT, name), encoding="utf-8") as f:
return json.loads(re.sub(r"^\s*//.*$", "", f.read(), flags=re.M))
keymap = load_sublime_json("Default.sublime-keymap")
assert len(keymap) > 100
# every printable must be bound, or sublime inserts it into the view and an app
# that does not echo (vim in normal mode) leaves the character behind. Each
# binding's key must also round-trip to that same character: the shift+<letter>
# form silently yields lowercase, which is why uppercase is bound literally.
import string # noqa
from vterm.keys import get_key_code
bound = {}
for entry in keymap:
combo = entry["keys"][0]
if len(combo) == 1 and entry["command"] == "vterm_keypress":
assert combo not in bound, "{} bound twice".format(combo)
bound[combo] = entry["args"]
for char in string.ascii_letters + string.digits + string.punctuation:
assert char in bound, "printable {!r} is not bound".format(char)
produced = get_key_code(**bound[char])
assert produced == char, "{!r} would send {!r}".format(char, produced)
assert get_key_code(key="space") == " ", "space must send a space"
load_sublime_json("Default (OSX).sublime-mousemap")
# scheme manager produces scopes and rules
import vterm.scheme as scheme_module
from vterm.scheme import scheme_manager
scope = scheme_manager.scope("ff0000", "default", "b")
assert scope == "vterm.ff0000.default.b", scope
# every command referenced by the palette and the keymap must exist, and the
# flags commands set must be the ones the renderer reads (a rename here is
# otherwise only caught at runtime)
import vterm.commands as commands
import vterm.render as render
import vterm.terminal as terminal_module
from vterm.terminal import Terminal
def command_class_name(name):
return "".join(part.title() for part in name.split("_")) + "Command"
for entry in load_sublime_json("vterm.sublime-commands") + keymap:
cls = command_class_name(entry["command"])
assert hasattr(commands, cls) or hasattr(render, cls), \
"{} -> missing {}".format(entry["command"], cls)
# every pending_* name mentioned in the modules must be a real Terminal
# attribute, so a renamed flag cannot leave a writer pointing at nothing
flags = {f for f in vars(Terminal(None)) if f.startswith("pending_")}
assert flags == {"pending_clear", "pending_clear_screen"}, flags
for module in (commands, render, terminal_module):
with open(module.__file__, encoding="utf-8") as f:
# (?<!_) skips the private _pending_to_send_string write-queue flag
for name in set(re.findall(r"(?<!_)pending_[a-z_]+", f.read())):
assert name in flags, "{}: {} is not a Terminal attribute".format(
os.path.basename(module.__file__), name)
# a theme is data, so a typo'd colour name would only show up as a silently
# unstyled scope at runtime
from vterm.emulator import ANSI_COLORS
from vterm.themes import THEMES
for name, theme in THEMES.items():
missing = (set(ANSI_COLORS) | {"foreground", "background"}) - set(theme)
assert not missing, "theme {}: missing {}".format(name, sorted(missing))
assert not set(theme) - (set(ANSI_COLORS) | {"foreground", "background"}), name
for key, value in theme.items():
assert re.fullmatch(r"#[0-9a-f]{6}", value), \
"theme {}: {} = {!r}".format(name, key, value)
configured = load_sublime_json("vterm.sublime-settings").get("theme")
assert configured is None or configured in THEMES, configured
# plugin_loaded must create the colour scheme: sublime loads a restored tab's
# color_scheme during startup, before any terminal opens
# import main.py the way sublime does - as a module of the package directory -
# so its relative imports and plugin_loaded body are actually executed
pkg = types.ModuleType("vterm")
pkg.__path__ = [ROOT]
sys.modules["vterm"] = pkg
main = importlib.import_module("vterm.main")
scheme_file = os.path.join(main.scheme_manager._directory(), scheme_module.SCHEME_FILE_NAME)
if os.path.exists(scheme_file):
os.remove(scheme_file)
main.plugin_loaded()
assert os.path.exists(scheme_file), \
"plugin_loaded must leave the colour scheme on disk"
print("import smoke test passed ({} keymap bindings, {} pending flags, "
"{} themes)".format(len(keymap), len(flags), len(THEMES)))

509
tests/test_render.py Normal file
View File

@@ -0,0 +1,509 @@
""" Render-layer tests against a fake view.
The live harness exercises the emulator only, so regressions in render.py used
to reach Sublime unnoticed. Run with: python3 tests/test_render.py
"""
import _bootstrap # sets sys.path for the imports below
from _bootstrap import FakeProcess
import sublime
from vterm.emulator import VtScreen, VtStream
from vterm.mouse import VtermMouseClickCommand, VtermScrollCommand
from vterm.render import (
CHUNK,
VtermRenderCommand,
focus_cursor,
)
from vterm.terminal import Terminal
class FakeSelection(list):
def clear(self):
del self[:]
def add(self, region):
self.append(region)
class FakeView:
"""Enough of the view API for the trim paths, with real line arithmetic."""
def __init__(self, text=""):
self.text = text
self.selection = FakeSelection()
self.regions = {}
self.commands = []
self.folds = []
self._name = ""
def size(self):
return len(self.text)
def rowcol(self, point):
head = self.text[:point]
row = head.count("\n")
return (row, point - (head.rfind("\n") + 1))
def text_point(self, row, col):
lines = self.text.split("\n")
start = sum(len(l) + 1 for l in lines[:row])
return start + min(col, len(lines[row]) if row < len(lines) else 0)
def line(self, point):
start = self.text.rfind("\n", 0, point) + 1
end = self.text.find("\n", point)
return sublime.Region(start, len(self.text) if end == -1 else end)
def full_line(self, point):
region = self.line(point)
return sublime.Region(region.begin(),
min(region.end() + 1, len(self.text)))
def lines(self, region):
out = []
point = region.begin()
while point <= region.end():
line = self.line(point)
out.append(line)
point = line.end() + 1
return out
def substr(self, region):
return self.text[region.begin():region.end()]
def erase(self, edit, region):
self.text = self.text[:region.begin()] + self.text[region.end():]
def replace(self, edit, region, value):
self.text = self.text[:region.begin()] + value + self.text[region.end():]
# sublime maps a caret inside the replaced span onto the new text,
# which is where the artefact selection came from
for i, sel in enumerate(list(self.selection)):
if sel.empty() and region.begin() <= sel.begin() <= region.end():
self.selection[i] = sublime.Region(region.begin(),
region.begin() + len(value))
def insert(self, edit, point, value):
self.text = self.text[:point] + value + self.text[point:]
return len(value)
def fold(self, region):
self.folds.append((region.begin(), region.end()))
return True
def unfold(self, region):
unfolded = self.folds
self.folds = []
return unfolded
def folded_regions(self):
return [sublime.Region(b, e) for b, e in self.folds]
def erase_regions(self, key):
self.regions.pop(key, None)
def add_regions(self, key, regions, scope):
self.regions[key] = (scope, [(r.begin(), r.end()) for r in regions])
def get_regions(self, key):
_, pairs = self.regions.get(key, ("", []))
return [sublime.Region(a, b) for a, b in pairs]
def settings(self):
return sublime.load_settings("fake")
def viewport_position(self):
return (0, 0)
def line_height(self):
return 20
def name(self):
return self._name
def set_name(self, name):
self._name = name
def run_command(self, name, args=None):
self.commands.append(name)
def sel(self):
return self.selection
def make_terminal(rows=10, cols=20, scrollback=None):
# real __init__ for the plain attributes; process/screen stand in for start()
terminal = Terminal(FakeView())
if scrollback is not None:
terminal.scrollback_size = scrollback
terminal.process = FakeProcess()
terminal.screen = VtScreen(cols, rows, process=terminal.process)
return terminal, VtStream(terminal.screen)
def make_command(view):
# the real __init__, so the fake cannot drift from the command's attributes
return VtermRenderCommand(view)
# an app on the alternate screen must not have its view trimmed away: the
# emulator's history deque is maxlen=0 there, so it cannot be the row cap
terminal, stream = make_terminal(rows=10)
stream.feed("\x1b[?1049h")
assert terminal.screen.alternate_buffer_mode
assert terminal.screen.history.maxlen == 0, "alt screen zeroes the deque"
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(10)))
command = make_command(terminal.view)
before = terminal.view.text
command.trim_history(None, terminal)
assert terminal.view.text == before, \
"alt screen view was trimmed: {!r}".format(terminal.view.text)
# the configured cap still applies on the primary screen
terminal, stream = make_terminal(rows=5, scrollback=20)
terminal.offset = 40
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(40)))
command = make_command(terminal.view)
command.trim_history(None, terminal)
rows_left = terminal.view.text.count("\n") + 1
assert rows_left <= 20, rows_left
assert "row 39" in terminal.view.text, "newest rows must survive"
assert "row 0" not in terminal.view.text, "oldest rows must be dropped"
# below the cap nothing is dropped
terminal, stream = make_terminal(rows=5, scrollback=5000)
terminal.offset = 3
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(8)))
command = make_command(terminal.view)
command.trim_history(None, terminal)
assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \
terminal.view.text
print("render tests passed (alt screen keeps its view, cap honoured)")
# a selection the user made must survive app output: focus_cursor runs on every
# frame, and clearing it there made click-drag selection impossible to hold
terminal, stream = make_terminal(rows=5, cols=20)
terminal.view = FakeView("hello world\nsecond line\n")
stream.feed("x")
selection = terminal.view.sel()
selection.add(sublime.Region(0, 5)) # user selected "hello"
focus_cursor(terminal.view, None, terminal)
assert len(selection) == 1 and selection[0].begin() == 0 and selection[0].end() == 5, \
"user selection was clobbered by a render"
# an empty selection (a plain click, or none at all) still gets the caret placed
# at the terminal cursor
selection.clear()
focus_cursor(terminal.view, None, terminal)
assert len(selection) == 1 and selection[0].empty(), \
"caret must be placed when nothing is selected"
print("selection tests passed (user selection survives renders, caret still tracked)")
# an app that hides the cursor (pi hides it for good) still needs the caret:
# sublime drops a space keypress in a view that has no caret at all
selection.clear()
stream.feed("\x1b[?25l")
focus_cursor(terminal.view, None, terminal)
assert len(selection) == 1 and selection[0].empty(), \
"caret must survive DECTCEM, or the space key stops working"
print("cursor visibility tests passed (hidden cursor keeps the caret)")
# colorize_line positions regions by arithmetic from the line start now, and
# batches the runs that share a scope into one add_regions call
terminal, stream = make_terminal(rows=3, cols=20)
terminal.view = FakeView()
command = make_command(terminal.view)
stream.feed("\x1b[31mRED\x1b[0m..\x1b[31mred\x1b[0m..\x1b[32mgreen")
command.update_lines(None, terminal)
painted = terminal.view.regions
scopes = sorted(scope for scope, _ in painted.values())
assert scopes == ["vterm.green.default", "vterm.red.default"], scopes
red = next(spans for scope, spans in painted.values() if scope.endswith("red.default"))
assert red == [(0, 3), (5, 8)], "two red runs must share one key: {}".format(red)
green = next(spans for scope, spans in painted.values() if scope.endswith("green.default"))
assert green == [(10, 15)], green
assert terminal.view.text.startswith("RED..red..green"), repr(terminal.view.text)
# the default-on-default run between them gets no region at all
assert len(painted) == 2, painted
# keys are unique per view and recorded so decolorize_line can erase them
assert sorted(terminal.view.regions) == sorted(command.colored_lines[0]), \
"colored_lines must track exactly the keys that were added"
command.decolorize_line(0)
assert terminal.view.regions == {}, "decolorize must erase every key it added"
print("colorize tests passed (arithmetic offsets, scope batching, key bookkeeping)")
# rows that leave the screen have their colours merged onto shared per-(scope,
# chunk) keys once the chunk closes; per-row keys would otherwise accumulate for
# the whole session, and merging every frame would re-send the whole chunk
terminal, stream = make_terminal(rows=3, cols=20)
terminal.view = FakeView()
command = make_command(terminal.view)
for i in range(300):
stream.feed("\x1b[31mred{}\x1b[0m\r\n".format(i))
command.update_lines(None, terminal)
terminal.screen.dirty.clear()
regions = terminal.view.regions
chunk = "vterm@0@vterm.red.default"
assert chunk in regions, sorted(regions)
_, spans = regions[chunk]
assert len(spans) == CHUNK, "the closed chunk merges its rows: {}".format(len(spans))
assert "vterm@1@vterm.red.default" not in regions, "an open chunk must not be sent yet"
row_keys = [k for k in regions if k.startswith("vterm#")]
assert len(row_keys) == 300 - CHUNK, \
"merged rows drop their per-row keys, the open chunk keeps them: {}".format(len(row_keys))
# trimming settles the open chunk, then erases the keys of chunks above the cut
terminal, stream = make_terminal(rows=3, scrollback=30)
terminal.view = FakeView()
command = make_command(terminal.view)
stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300)))
command.update_lines(None, terminal)
terminal.screen.dirty.clear()
assert set(command._archived_chunks) == {0}, command._archived_chunks
command.trim_history(None, terminal)
assert "vterm@0@vterm.red.default" not in terminal.view.regions, "trimmed chunk key left behind"
assert 0 not in command._archived_chunks
assert "vterm@1@vterm.red.default" in terminal.view.regions, "boundary chunk must survive"
print("scrollback archive tests passed (chunk keys bounded, trim erases whole chunks)")
# a fresh terminal must not be left with the prompt selected: our own replace
# expands the caret across the inserted text, and sublime paints that with the
# selection colour (its default is pure blue)
terminal, stream = make_terminal(rows=3, cols=20)
terminal.view = FakeView()
terminal.view.sel().add(sublime.Region(0, 0)) # the caret of a new view
Terminal._terminals[id(terminal)] = terminal
terminal.view.id = lambda: id(terminal)
terminal.default_title = "zsh"
command = make_command(terminal.view)
stream.feed("[vterm] %")
command.run(None)
assert all(r.empty() for r in terminal.view.sel()), \
"prompt was left selected: {}".format([(r.begin(), r.end()) for r in terminal.view.sel()])
# but a selection the user already had is still respected
terminal.view.sel().clear()
terminal.view.sel().add(sublime.Region(2, 8))
stream.feed("\x1b[2Kmore output")
command.run(None)
kept = [(r.begin(), r.end()) for r in terminal.view.sel()]
assert any(b != e for b, e in kept), "user selection was dropped: {}".format(kept)
# pyte's dirty is a repaint hint: rows whose content did not change must not
# be rewritten (each rewrite is a cross-process call and an on_modified)
rewritten = []
_replace = terminal.view.replace
terminal.view.replace = lambda edit, region, value: (rewritten.append(value), _replace(edit, region, value))[1]
terminal.screen.dirty.update(range(terminal.screen.lines))
command.run(None)
terminal.view.replace = _replace
# blank tail rows still churn via trim_trailing_spaces; content rows must not
assert all(text == "" for text in rewritten), "unchanged rows were rewritten: {}".format(rewritten)
print("render diff tests passed (unchanged dirty rows skipped)")
# the generated scheme must define a selection colour, or sublime uses blue
from vterm.scheme import blend, scheme_manager
scheme_manager.background = "#303841"
scheme_manager.foreground = "#d8dee9"
assert blend(scheme_manager.background, scheme_manager.foreground, 0.3) == "#626a73"
print("selection artefact tests passed (fresh prompt unselected, user selection kept)")
# DECSCUSR drives the caret shape through view settings; before any app asks,
# and for 0, the caret is the terminal default: a blinking block
terminal, stream = make_terminal(rows=3, cols=20)
command = make_command(terminal.view)
settings = terminal.view.settings()
command.sync_cursor_style(terminal)
assert settings.get("block_caret") is True, settings.get("block_caret")
assert settings.get("caret_style") == "blink", settings.get("caret_style")
stream.feed("\x1b[2 q")
command.sync_cursor_style(terminal)
assert settings.get("block_caret") is True, settings.get("block_caret")
assert settings.get("caret_style") == "solid", settings.get("caret_style")
stream.feed("\x1b[5 q")
command.sync_cursor_style(terminal)
assert settings.get("block_caret") is False, settings.get("block_caret")
assert settings.get("caret_style") == "blink", settings.get("caret_style")
stream.feed("\x1b[0 q")
command.sync_cursor_style(terminal)
assert settings.get("block_caret") is True, settings.get("block_caret")
assert settings.get("caret_style") == "blink", settings.get("caret_style")
# a hidden cursor outranks DECSCUSR: the caret stays for typing, as a block on
# the cell the app draws its own cursor over
stream.feed("\x1b[5 q\x1b[?25l")
command.sync_cursor_style(terminal)
assert settings.get("block_caret") is True, settings.get("block_caret")
assert settings.get("caret_style") == "solid", settings.get("caret_style")
stream.feed("\x1b[?25h")
command.sync_cursor_style(terminal)
assert settings.get("block_caret") is False, settings.get("block_caret")
assert settings.get("caret_style") == "blink", settings.get("caret_style")
print("cursor style tests passed (DECSCUSR maps to caret settings, 0 is the default block)")
# wheel over an alt-screen app that does not track the mouse must fall back to
# arrow keys (alternate scroll); with tracking on, SGR reports as before
terminal, stream = make_terminal(rows=5, cols=20)
Terminal._terminals[id(terminal)] = terminal
terminal.view.id = lambda: id(terminal)
scroll = VtermScrollCommand.__new__(VtermScrollCommand)
scroll.view = terminal.view
stream.feed("\x1b[?1049h")
scroll.run(None, up=True)
assert terminal.process.written == "\x1b[A" * 3, repr(terminal.process.written)
# application cursor mode switches the arrow encoding
terminal.process.written = ""
stream.feed("\x1b[?1h")
scroll.run(None, up=False)
assert terminal.process.written == "\x1bOB" * 3, repr(terminal.process.written)
# with mouse tracking on, the wheel is reported, not translated: one report per
# line, aimed mid-screen when there is no event to place it
terminal.process.written = ""
stream.feed("\x1b[?1002h\x1b[?1006h")
scroll.run(None, up=True)
assert terminal.process.writes == ["\x1b[<64;1;3M"] * 3, terminal.process.writes
# on the primary screen without tracking the command stays inert
terminal.process.written = ""
stream.feed("\x1b[?1002l\x1b[?1006l\x1b[?1049l")
scroll.run(None, up=True)
assert terminal.process.written == "", repr(terminal.process.written)
print("alternate scroll tests passed (3 lines per tick: arrows on alt screen, SGR when tracking, inert otherwise)")
# a click in an unfocused group must focus the tab instead of reporting: we
# replaced drag_select, which is what sublime focuses the group from
class FakeWindow:
def __init__(self, active):
self.active = active
def active_view(self):
return self.active
def focus_view(self, view):
self.active = view
terminal, stream = make_terminal(rows=5, cols=20)
Terminal._terminals[id(terminal)] = terminal
terminal.view.id = lambda: id(terminal)
terminal.view.window_to_text = lambda xy: 0
window = FakeWindow(active=object())
terminal.view.window = lambda: window
stream.feed("\x1b[?1002h\x1b[?1006h")
click = VtermMouseClickCommand.__new__(VtermMouseClickCommand)
click.view = terminal.view
event = {"x": 0, "y": 0, "button": 1}
click.run(None, event)
assert window.active_view() is terminal.view, "click did not focus the tab"
assert terminal.process.written == "", repr(terminal.process.written)
# once focused, the same click reports as before
click.run(None, event)
assert terminal.process.written == "\x1b[<0;1;1M\x1b[<0;1;1m", repr(terminal.process.written)
print("click focus tests passed (first click focuses only, then reports)")
# entering the alt screen folds the scrollback away (kitty hides it); leaving
# unfolds it, and the fold resets the follow anchor so the gate re-engages.
# the fold ends one character before the screen's first row, out of reach of
# the per-frame replaces of that row
terminal, stream = make_terminal(rows=5, cols=20)
terminal.view = FakeView("old1\nold2\nold3\nr0\nr1\nr2\nr3\nr4\n")
terminal.offset = 3
terminal.viewport_y = 400
command = make_command(terminal.view)
fold_end = terminal.view.text_point(3, 0) - 1
terminal.view.settings().set("scroll_past_end", True)
stream.feed("\x1b[?1049h")
command.sync_alt_fold(terminal)
assert terminal.view.folds == [(0, fold_end)], terminal.view.folds
assert terminal.viewport_y == 0, terminal.viewport_y
assert terminal.view.settings().get("scroll_past_end") is False, "alt screen must not be scrollable"
# a second render leaves the fold alone
command.sync_alt_fold(terminal)
assert terminal.view.folds == [(0, fold_end)], terminal.view.folds
# a disturbed fold is re-asserted on the next render
terminal.view.folds = [(0, fold_end - 2)]
command.sync_alt_fold(terminal)
assert terminal.view.folds == [(0, fold_end)], terminal.view.folds
stream.feed("\x1b[?1049l")
command.sync_alt_fold(terminal)
assert terminal.view.folds == [], terminal.view.folds
assert terminal.view.settings().get("scroll_past_end") is True, "primary screen scrolls past end again"
# without scrollback there is nothing to fold, but the scroll lock still applies
terminal, stream = make_terminal(rows=5, cols=20)
terminal.view = FakeView("r0\nr1\nr2\nr3\nr4\n")
command = make_command(terminal.view)
stream.feed("\x1b[?1049h")
command.sync_alt_fold(terminal)
assert terminal.view.folds == [], terminal.view.folds
assert terminal.view.settings().get("scroll_past_end") is False, "alt screen must not be scrollable"
print("alt fold tests passed (scrollback folded on entry, restored on exit)")
# a row whose first cells were never written: they read back as default cells
# (StaticDefaultDict), so they form their own uncoloured run and the coloured
# run that follows keeps the right column
from pyte.screens import StaticDefaultDict
from vterm.emulator import Char
from vterm.render import segment_buffer_line
line = StaticDefaultDict(Char(" "))
line[3] = Char("X", "red", "default")
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
assert runs == [(" ", 0, 3, "default", "default", ""),
("X", 3, 4, "red", "default", "")], runs
# wide characters consume two columns but one character of text
line = StaticDefaultDict(Char(" "))
line[0] = Char("漢", "red", "default")
line[1] = Char("", "red", "default")
line[2] = Char("x", "blue", "default")
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
assert runs == [("漢", 0, 1, "red", "default", ""), ("x", 1, 2, "blue", "default", "")], runs
# reverse video swaps the pair, and default becomes reverse_default
line = StaticDefaultDict(Char(" "))
line[0] = Char("r", "red", "default", False, False, False, False, True)
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
assert runs == [("r", 0, 1, "reverse_default", "red", "")], runs
print("segmenter tests passed (leading gap, wide char, reverse)")