Files
babi/tests/features/conftest.py
2020-08-29 12:40:12 -07:00

478 lines
14 KiB
Python

import contextlib
import curses
import os
import sys
from typing import List
from typing import NamedTuple
from typing import Tuple
from typing import Union
from unittest import mock
import pytest
from babi._types import Protocol
from babi.main import main
from babi.screen import VERSION_STR
from testing.runner import PrintsErrorRunner
@pytest.fixture(autouse=True)
def prefix_home(tmpdir):
prefix_home = tmpdir.join('prefix_home')
with mock.patch.object(sys, 'prefix', str(prefix_home)):
yield prefix_home
@pytest.fixture(autouse=True)
def xdg_data_home(tmpdir):
data_home = tmpdir.join('data_home')
with mock.patch.dict(os.environ, {'XDG_DATA_HOME': str(data_home)}):
yield data_home
@pytest.fixture(autouse=True)
def xdg_config_home(tmpdir):
config_home = tmpdir.join('config_home')
with mock.patch.dict(os.environ, {'XDG_CONFIG_HOME': str(config_home)}):
yield config_home
@pytest.fixture
def ten_lines(tmpdir):
f = tmpdir.join('f')
f.write('\n'.join(f'line_{i}' for i in range(10)))
return f
class Screen:
def __init__(self, width, height):
self.nodelay = False
self.width = width
self.height = height
self.lines = [' ' * self.width for _ in range(self.height)]
self.attrs = [[(0, 0, 0)] * self.width for _ in range(self.height)]
self.x = self.y = 0
self._prev_screenshot = None
def screenshot(self):
ret = ''.join(f'{line.rstrip()}\n' for line in self.lines)
if ret != self._prev_screenshot:
print('=' * 79)
print(ret, end='')
print('=' * 79)
self._prev_screenshot = ret
return ret
def addstr(self, y, x, s, attr):
self.lines[y] = self.lines[y][:x] + s + self.lines[y][x + len(s):]
line_attr = self.attrs[y]
new = [attr] * len(s)
self.attrs[y] = line_attr[:x] + new + line_attr[x + len(s):]
self.y = y
self.x = x + len(s)
def insstr(self, y, x, s, attr):
line = self.lines[y]
self.lines[y] = (line[:x] + s + line[x:])[:self.width]
line_attr = self.attrs[y]
new = [attr] * len(s)
self.attrs[y] = (line_attr[:x] + new + line_attr[x:])[:self.width]
def chgat(self, y, x, n, attr):
assert n >= 0 # TODO: switch to > 0, we should never do 0-length
self.attrs[y][x:x + n] = [attr] * n
def move(self, y, x):
assert 0 <= y < self.height
assert 0 <= x < self.width
print(f'MOVE: y: {y}, x: {x}')
self.y, self.x = y, x
def resize(self, *, width, height):
if height > self.height:
self.lines.extend([''] * (height - self.height))
else:
self.lines = self.lines[:height]
if width > self.width:
self.lines[:] = [line.ljust(width) for line in self.lines]
else:
self.lines[:] = [line[:width] for line in self.lines]
self.width, self.height = width, height
class Op(Protocol):
def __call__(self, screen: Screen) -> None: ...
class AwaitText(NamedTuple):
text: str
def __call__(self, screen: Screen) -> None:
if self.text not in screen.screenshot():
raise AssertionError(f'expected: {self.text!r}')
class AwaitTextMissing(NamedTuple):
text: str
def __call__(self, screen: Screen) -> None:
if self.text in screen.screenshot():
raise AssertionError(f'expected missing: {self.text!r}')
class AwaitCursorPosition(NamedTuple):
x: int
y: int
def __call__(self, screen: Screen) -> None:
assert (self.x, self.y) == (screen.x, screen.y)
class AssertCursorLineEquals(NamedTuple):
line: str
def __call__(self, screen: Screen) -> None:
assert screen.lines[screen.y].rstrip() == self.line
class AssertScreenLineEquals(NamedTuple):
n: int
line: str
def __call__(self, screen: Screen) -> None:
assert screen.lines[self.n].rstrip() == self.line
class AssertScreenAttrEquals(NamedTuple):
n: int
attr: List[Tuple[int, int, int]]
def __call__(self, screen: Screen) -> None:
assert screen.attrs[self.n] == self.attr
class AssertFullContents(NamedTuple):
contents: str
def __call__(self, screen: Screen) -> None:
assert screen.screenshot() == self.contents
class Resize(NamedTuple):
width: int
height: int
def __call__(self, screen: Screen) -> None:
screen.resize(width=self.width, height=self.height)
class KeyPress(NamedTuple):
wch: Union[int, str]
def __call__(self, screen: Screen) -> None:
raise AssertionError('unreachable')
class CursesError(NamedTuple):
def __call__(self, screen: Screen) -> None:
if screen.nodelay:
raise curses.error()
class CursesScreen:
def __init__(self, screen, runner):
self._screen = screen
self._runner = runner
self._bkgd_attr = (-1, -1, 0)
def _to_attr(self, attr):
if attr == 0:
return self._bkgd_attr
else:
pair = (attr & (0xff << 8)) >> 8
if pair == 0:
fg, bg, _ = self._bkgd_attr
else:
fg, bg = self._runner.color_pairs[pair]
attr = attr & ~(0xff << 8)
return (fg, bg, attr)
def bkgd(self, c, attr):
assert c == ' '
self._bkgd_attr = self._to_attr(attr)
def keypad(self, val):
pass
def nodelay(self, val):
self._screen.nodelay = val
def addstr(self, y, x, s, attr=0):
self._screen.addstr(y, x, s, self._to_attr(attr))
def insstr(self, y, x, s, attr=0):
self._screen.insstr(y, x, s, self._to_attr(attr))
def clrtoeol(self):
s = self._screen.width * ' '
self.insstr(self._screen.y, self._screen.x, s)
def chgat(self, y, x, n, attr):
self._screen.chgat(y, x, n, self._to_attr(attr))
def move(self, y, x):
self._screen.move(y, x)
def getyx(self):
return self._screen.y, self._screen.x
def get_wch(self):
return self._runner._get_wch()
class Key(NamedTuple):
tmux: str
curses: bytes
wch: Union[int, str]
@property
def value(self) -> int:
return self.wch if isinstance(self.wch, int) else ord(self.wch)
KEYS = [
Key('Enter', b'^M', '\r'),
Key('Tab', b'^I', '\t'),
Key('BTab', b'KEY_BTAB', curses.KEY_BTAB),
Key('DC', b'KEY_DC', curses.KEY_DC),
Key('BSpace', b'KEY_BACKSPACE', curses.KEY_BACKSPACE),
Key('Up', b'KEY_UP', curses.KEY_UP),
Key('Down', b'KEY_DOWN', curses.KEY_DOWN),
Key('Right', b'KEY_RIGHT', curses.KEY_RIGHT),
Key('Left', b'KEY_LEFT', curses.KEY_LEFT),
Key('Home', b'KEY_HOME', curses.KEY_HOME),
Key('End', b'KEY_END', curses.KEY_END),
Key('PageUp', b'KEY_PPAGE', curses.KEY_PPAGE),
Key('PageDown', b'KEY_NPAGE', curses.KEY_NPAGE),
Key('^Up', b'kUP5', 566),
Key('^Down', b'kDN5', 525),
Key('^Right', b'kRIT5', 560),
Key('^Left', b'kLFT5', 545),
Key('^Home', b'kHOM5', 535),
Key('^End', b'kEND5', 530),
Key('M-Right', b'kRIT3', 558),
Key('M-Left', b'kLFT3', 543),
Key('S-Up', b'KEY_SR', curses.KEY_SR),
Key('S-Down', b'KEY_SF', curses.KEY_SF),
Key('S-Right', b'KEY_SRIGHT', curses.KEY_SRIGHT),
Key('S-Left', b'KEY_SLEFT', curses.KEY_SLEFT),
Key('S-Home', b'KEY_SHOME', curses.KEY_SHOME),
Key('S-End', b'KEY_SEND', curses.KEY_SEND),
Key('^A', b'^A', '\x01'),
Key('^C', b'^C', '\x03'),
Key('^H', b'^H', '\x08'),
Key('^K', b'^K', '\x0b'),
Key('^E', b'^E', '\x05'),
Key('^J', b'^J', '\n'),
Key('^O', b'^O', '\x0f'),
Key('^P', b'^P', '\x10'),
Key('^R', b'^R', '\x12'),
Key('^S', b'^S', '\x13'),
Key('^U', b'^U', '\x15'),
Key('^V', b'^V', '\x16'),
Key('^W', b'^W', '\x17'),
Key('^X', b'^X', '\x18'),
Key('^Y', b'^Y', '\x19'),
Key('^[', b'^[', '\x1b'),
Key('^_', b'^_', '\x1f'),
Key('^\\', b'^\\', '\x1c'),
Key('!resize', b'KEY_RESIZE', curses.KEY_RESIZE),
]
KEYS_TMUX = {k.tmux: k.wch for k in KEYS}
KEYS_CURSES = {k.value: k.curses for k in KEYS}
class DeferredRunner:
def __init__(self, command, width=80, height=24, term='screen'):
self.command = command
self._i = 0
self._ops: List[Op] = []
self.color_pairs = {0: (7, 0)}
self.screen = Screen(width, height)
self._n_colors, self._can_change_color = {
'xterm-mono': (0, False),
'screen': (8, False),
'screen-256color': (256, False),
'xterm-256color': (256, True),
}[term]
def _get_wch(self):
while not isinstance(self._ops[self._i], KeyPress):
self._i += 1
try:
self._ops[self._i - 1](self.screen)
except AssertionError: # pragma: no cover (only on failures)
self.screen.screenshot()
raise
self._i += 1
keypress_event = self._ops[self._i - 1]
assert isinstance(keypress_event, KeyPress)
print(f'KEY: {keypress_event.wch!r}')
return keypress_event.wch
def await_text(self, text, timeout=1):
self._ops.append(AwaitText(text))
def await_text_missing(self, text):
self._ops.append(AwaitTextMissing(text))
def await_cursor_position(self, *, x, y):
self._ops.append(AwaitCursorPosition(x, y))
def assert_cursor_line_equals(self, line):
self._ops.append(AssertCursorLineEquals(line))
def assert_screen_line_equals(self, n, line):
self._ops.append(AssertScreenLineEquals(n, line))
def assert_screen_attr_equals(self, n, attr):
self._ops.append(AssertScreenAttrEquals(n, attr))
def assert_full_contents(self, contents):
self._ops.append(AssertFullContents(contents))
def run(self, callback):
self._ops.append(lambda screen: callback())
def _expand_key(self, s):
if s == 'Escape':
return [KeyPress('\x1b'), CursesError()]
elif s in KEYS_TMUX:
return [KeyPress(KEYS_TMUX[s])]
elif s.startswith('^') and len(s) > 1 and s[1].isupper():
raise AssertionError(f'unknown key {s}')
elif s.startswith('M-'):
return [KeyPress('\x1b'), KeyPress(s[2:]), CursesError()]
else:
return [*(KeyPress(k) for k in s), CursesError()]
def press(self, s):
self._ops.extend(self._expand_key(s))
def press_and_enter(self, s):
self.press(s)
self.press('Enter')
def press_sequence(self, *ks):
for k in ks:
for op in self._expand_key(k):
if not isinstance(op, CursesError):
self._ops.append(op)
self._ops.append(CursesError())
def answer_no_if_modified(self):
self.press('n')
@contextlib.contextmanager
def resize(self, *, width, height):
orig_width, orig_height = self.screen.width, self.screen.height
self._ops.append(Resize(width, height))
self._ops.append(KeyPress(curses.KEY_RESIZE))
try:
yield
finally:
self._ops.append(Resize(orig_width, orig_height))
self._ops.append(KeyPress(curses.KEY_RESIZE))
def _curses__noop(self, *_, **__):
pass
_curses_cbreak = _curses_endwin = _curses_noecho = _curses__noop
_curses_nonl = _curses_raw = _curses_use_default_colors = _curses__noop
_curses_set_escdelay = _curses__noop
_curses_error = curses.error # so we don't mock the exception
def _curses_keyname(self, k):
return KEYS_CURSES.get(k, b'')
def _curses_update_lines_cols(self):
curses.LINES = self.screen.height
curses.COLS = self.screen.width
def _curses_start_color(self):
curses.COLORS = self._n_colors
def _curses_can_change_color(self):
return self._can_change_color
def _curses_init_pair(self, pair, fg, bg):
self.color_pairs[pair] = (fg, bg)
def _curses_color_pair(self, pair):
assert pair in self.color_pairs
return pair << 8
def _curses_initscr(self):
self._curses_update_lines_cols()
return CursesScreen(self.screen, self)
def _curses_newwin(self, height, width):
return CursesScreen(Screen(width, height), self)
def _curses_not_implemented(self, fn):
def fn_inner(*args, **kwargs):
raise NotImplementedError(fn)
return fn_inner
def _patch_curses(self):
patches = {
k: getattr(self, f'_curses_{k}', self._curses_not_implemented(k))
for k in dir(curses)
if not k.startswith('_') and callable(getattr(curses, k))
}
return mock.patch.multiple(curses, **patches)
def await_exit(self):
with self._patch_curses():
main(self.command)
# we have already exited -- check remaining things
# KeyPress with failing condition or error
for i in range(self._i, len(self._ops)):
if self._ops[i] not in {KeyPress('n'), CursesError()}:
raise AssertionError(self._ops[i:])
@contextlib.contextmanager
def run_fake(*cmd, **kwargs):
h = DeferredRunner(cmd, **kwargs)
h.await_text(VERSION_STR)
yield h
@contextlib.contextmanager
def run_tmux(*args, term='screen', **kwargs):
cmd = (sys.executable, '-mcoverage', 'run', '-m', 'babi', *args)
cmd = ('env', f'TERM={term}', *cmd)
with PrintsErrorRunner(*cmd, **kwargs) as h, h.on_error():
# startup with coverage can be slow
h.await_text(VERSION_STR, timeout=2)
yield h
@pytest.fixture(
scope='session',
params=[run_fake, run_tmux],
ids=['fake', 'tmux'],
)
def run(request):
return request.param
@pytest.fixture(scope='session', params=[run_fake], ids=['fake'])
def run_only_fake(request):
return request.param