Files
neovim/test/functional/ui/screen.lua
Thiago de Arruda c546875daf ui: Replace cursor_{on,off} by busy_{stop,start}
Switching cursor off is only necessary in two occasions:

- When redrawing to avoid terminal flickering
- When the editor is busy

The first can now be handled by the TUI, so most calls to ui_cursor_off can be
removed from the core.

So, before this commit it was only necessary to switch the cursor off to notify
the user that nvim was running some long operation. Now the cursor_{on,off}
functions have been replaced by busy_{stop,start} which can be handled in a
UI-specific way(turning the cursor off or showing a busy indicator, for
example).

To make things even more simpler, nvim is always busy except when waiting for
user input or other asynchronous events: It automatically switches to a non-busy
state when the event loop is about to be entered for more than 100 milliseconds.

`ui_busy_start` can be called when its not desired to change the busy state in
the event loop (As its now done by functions that perform blocking shell
invocations).
2015-03-15 10:30:59 -03:00

556 lines
16 KiB
Lua

-- This module contains the Screen class, a complete Nvim screen implementation
-- designed for functional testing. The goal is to provide a simple and
-- intuitive API for verifying screen state after a set of actions.
--
-- The screen class exposes a single assertion method, "Screen:expect". This
-- method takes a string representing the expected screen state and an optional
-- set of attribute identifiers for checking highlighted characters(more on
-- this later).
--
-- The string passed to "expect" will be processed according to these rules:
--
-- - Each line of the string represents and is matched individually against
-- a screen row.
-- - The entire string is stripped of common indentation
-- - Expected screen rows are stripped of the last character. The last
-- character should be used to write pipes(|) that make clear where the
-- screen ends
-- - The last line is stripped, so the string must have (row count + 1)
-- lines.
--
-- Example usage:
--
-- local screen = Screen.new(25, 10)
-- -- attach the screen to the current Nvim instance
-- screen:attach()
-- --enter insert mode and type some text
-- feed('ihello screen')
-- -- declare an expectation for the eventual screen state
-- screen:expect([[
-- hello screen |
-- ~ |
-- ~ |
-- ~ |
-- ~ |
-- ~ |
-- ~ |
-- ~ |
-- ~ |
-- -- INSERT -- |
-- ]]) -- <- Last line is stripped
--
-- Since screen updates are received asynchronously, "expect" is actually
-- specifying the eventual screen state. This is how "expect" works: It will
-- start the event loop with a timeout of 5 seconds. Each time it receives an
-- update the expected state will be checked against the updated state.
--
-- If the expected state matches the current state, the event loop will be
-- stopped and "expect" will return. If the timeout expires, the last match
-- error will be reported and the test will fail.
--
-- If the second argument is passed to "expect", the screen rows will be
-- transformed before being matched against the string lines. The
-- transformation rule is simple: Each substring "S" composed with characters
-- having the exact same set of attributes will be substituted by "{K:S}",
-- where K is a key associated the attribute set via the second argument of
-- "expect".
-- If a transformation table is present, unexpected attribute sets in the final
-- state is considered an error. To make testing simpler, a list of attribute
-- sets that should be ignored can be passed as a third argument. Alternatively,
-- this third argument can be "true" to indicate that all unexpected attribute
-- sets should be ignored.
--
-- To illustrate how this works, let's say that in the above example we wanted
-- to assert that the "-- INSERT --" string is highlighted with the bold
-- attribute(which normally is), here's how the call to "expect" should look
-- like:
--
-- NonText = Screen.colors.Blue
-- screen:expect([[
-- hello screen \
-- ~ \
-- ~ \
-- ~ \
-- ~ \
-- ~ \
-- ~ \
-- ~ \
-- ~ \
-- {b:-- INSERT --} \
-- ]], {b = {bold = true}}, {{bold = true, foreground = NonText}})
--
-- In this case "b" is a string associated with the set composed of one
-- attribute: bold. Note that since the {b:} markup is not a real part of the
-- screen, the delimiter(|) had to be moved right. Also, the highlighting of the
-- NonText markers (~) is ignored in this test.
--
-- Multiple expect:s will likely share a group of attribute sets to test.
-- Therefore these could be specified at the beginning of a test like this:
-- NonText = Screen.colors.Blue
-- screen:set_default_attr_ids( {
-- [1] = {reverse = true, bold = true},
-- [2] = {reverse = true}
-- })
-- screen:set_default_attr_ignore( {{}, {bold=true, foreground=NonText}} )
-- These can be overridden for a specific expect expression, by passing
-- different sets as parameters.
--
-- To help writing screen tests, there is a utility function
-- "screen:snapshot_util()", that can be placed in a test file at any point an
-- "expect(...)" should be. It will wait a short amount of time and then dump
-- the current state of the screen, in the form of an "expect(..)" expression
-- that would match it exactly. "snapshot_util" optionally also take the
-- transformation and ignore set as parameters, like expect, or uses the default
-- set. It will generate a larger attribute transformation set, if needed.
-- To generate a text-only test without highlight checks,
-- use `screen:snapshot_util({},true)`
local helpers = require('test.functional.helpers')
local request, run, stop = helpers.request, helpers.run, helpers.stop
local eq, dedent = helpers.eq, helpers.dedent
local Screen = {}
Screen.__index = Screen
local debug_screen
local default_screen_timeout = 3500
if os.getenv('VALGRIND') then
default_screen_timeout = default_screen_timeout * 3
end
if os.getenv('CI_TARGET') then
default_screen_timeout = default_screen_timeout * 3
end
local colors = request('vim_get_color_map')
local colornames = {}
for name, rgb in pairs(colors) do
-- we disregard the case that colornames might not be unique, as
-- this is just a helper to get any canonical name of a color
colornames[rgb] = name
end
Screen.colors = colors
function Screen.debug(command)
if not command then
command = 'pynvim -n -c '
end
command = command .. request('vim_eval', '$NVIM_LISTEN_ADDRESS')
if debug_screen then
debug_screen:close()
end
debug_screen = io.popen(command, 'r')
debug_screen:read()
end
function Screen.new(width, height)
if not width then
width = 53
end
if not height then
height = 14
end
local self = setmetatable({
title = '',
icon = '',
bell = false,
visual_bell = false,
suspended = false,
_default_attr_ids = nil,
_default_attr_ignore = nil,
_mode = 'normal',
_mouse_enabled = true,
_attrs = {},
_cursor = {
row = 1, col = 1
},
_busy = true
}, Screen)
self:_handle_resize(width, height)
return self
end
function Screen:set_default_attr_ids(attr_ids)
self._default_attr_ids = attr_ids
end
function Screen:set_default_attr_ignore(attr_ignore)
self._default_attr_ignore = attr_ignore
end
function Screen:attach()
request('ui_attach', self._width, self._height, true)
end
function Screen:detach()
request('ui_detach')
end
function Screen:try_resize(columns, rows)
request('ui_try_resize', columns, rows)
end
function Screen:expect(expected, attr_ids, attr_ignore)
-- remove the last line and dedent
expected = dedent(expected:gsub('\n[ ]+$', ''))
local expected_rows = {}
for row in expected:gmatch('[^\n]+') do
-- the last character should be the screen delimiter
row = row:sub(1, #row - 1)
table.insert(expected_rows, row)
end
local ids = attr_ids or self._default_attr_ids
local ignore = attr_ignore or self._default_attr_ignore
self:wait(function()
for i = 1, self._height do
local expected_row = expected_rows[i]
local actual_row = self:_row_repr(self._rows[i], ids, ignore)
if expected_row ~= actual_row then
return 'Row '..tostring(i)..' didnt match.\nExpected: "'..
expected_row..'"\nActual: "'..actual_row..'"'
end
end
end)
end
function Screen:wait(check, timeout)
local err, checked = false
local function notification_cb(method, args)
assert(method == 'redraw')
self:_redraw(args)
err = check()
checked = true
if not err then
stop()
end
return true
end
run(nil, notification_cb, nil, timeout or default_screen_timeout)
if not checked then
err = check()
end
if err then
assert(false, err)
end
end
function Screen:_redraw(updates)
for _, update in ipairs(updates) do
-- print('--')
-- print(require('inspect')(update))
local method = update[1]
for i = 2, #update do
local handler = self['_handle_'..method]
handler(self, unpack(update[i]))
end
-- print(self:_current_screen())
end
end
function Screen:_handle_resize(width, height)
local rows = {}
for i = 1, height do
local cols = {}
for j = 1, width do
table.insert(cols, {text = ' ', attrs = {}})
end
table.insert(rows, cols)
end
self._cursor.row = 1
self._cursor.col = 1
self._rows = rows
self._width = width
self._height = height
self._scroll_region = {
top = 1, bot = height, left = 1, right = width
}
end
function Screen:_handle_clear()
self:_clear_block(self._scroll_region.top, self._scroll_region.bot,
self._scroll_region.left, self._scroll_region.right)
end
function Screen:_handle_eol_clear()
local row, col = self._cursor.row, self._cursor.col
self:_clear_block(row, row, col, self._scroll_region.right)
end
function Screen:_handle_cursor_goto(row, col)
self._cursor.row = row + 1
self._cursor.col = col + 1
end
function Screen:_handle_busy_start()
self._busy = true
end
function Screen:_handle_busy_stop()
self._busy = false
end
function Screen:_handle_mouse_on()
self._mouse_enabled = true
end
function Screen:_handle_mouse_off()
self._mouse_enabled = false
end
function Screen:_handle_insert_mode()
self._mode = 'insert'
end
function Screen:_handle_normal_mode()
self._mode = 'normal'
end
function Screen:_handle_set_scroll_region(top, bot, left, right)
self._scroll_region.top = top + 1
self._scroll_region.bot = bot + 1
self._scroll_region.left = left + 1
self._scroll_region.right = right + 1
end
function Screen:_handle_scroll(count)
local top = self._scroll_region.top
local bot = self._scroll_region.bot
local left = self._scroll_region.left
local right = self._scroll_region.right
local start, stop, step
if count > 0 then
start = top
stop = bot - count
step = 1
else
start = bot
stop = top - count
step = -1
end
-- shift scroll region
for i = start, stop, step do
local target = self._rows[i]
local source = self._rows[i + count]
for j = left, right do
target[j].text = source[j].text
target[j].attrs = source[j].attrs
end
end
-- clear invalid rows
for i = stop + step, stop + count, step do
self:_clear_row_section(i, left, right)
end
end
function Screen:_handle_highlight_set(attrs)
self._attrs = attrs
end
function Screen:_handle_put(str)
local cell = self._rows[self._cursor.row][self._cursor.col]
cell.text = str
cell.attrs = self._attrs
self._cursor.col = self._cursor.col + 1
end
function Screen:_handle_bell()
self.bell = true
end
function Screen:_handle_visual_bell()
self.visual_bell = true
end
function Screen:_handle_update_fg(fg)
self._fg = fg
end
function Screen:_handle_update_bg(bg)
self._bg = bg
end
function Screen:_handle_suspend()
self.suspended = true
end
function Screen:_handle_set_title(title)
self.title = title
end
function Screen:_handle_set_icon(icon)
self.icon = icon
end
function Screen:_clear_block(top, bot, left, right)
for i = top, bot do
self:_clear_row_section(i, left, right)
end
end
function Screen:_clear_row_section(rownum, startcol, stopcol)
local row = self._rows[rownum]
for i = startcol, stopcol do
row[i].text = ' '
row[i].attrs = {}
end
end
function Screen:_row_repr(row, attr_ids, attr_ignore)
local rv = {}
local current_attr_id
for i = 1, self._width do
local attr_id = get_attr_id(attr_ids, attr_ignore, row[i].attrs)
if current_attr_id and attr_id ~= current_attr_id then
-- close current attribute bracket, add it before any whitespace
-- up to the current cell
-- table.insert(rv, backward_find_meaningful(rv, i), '}')
table.insert(rv, '}')
current_attr_id = nil
end
if not current_attr_id and attr_id then
-- open a new attribute bracket
table.insert(rv, '{' .. attr_id .. ':')
current_attr_id = attr_id
end
if self._rows[self._cursor.row] == row and self._cursor.col == i then
table.insert(rv, '^')
end
table.insert(rv, row[i].text)
end
if current_attr_id then
table.insert(rv, '}')
end
-- return the line representation, but remove empty attribute brackets and
-- trailing whitespace
return table.concat(rv, '')--:gsub('%s+$', '')
end
function Screen:_current_screen()
-- get a string that represents the current screen state(debugging helper)
local rv = {}
for i = 1, self._height do
table.insert(rv, "'"..self:_row_repr(self._rows[i]).."'")
end
return table.concat(rv, '\n')
end
function Screen:snapshot_util(attrs, ignore)
-- util to generate screen test
pcall(function() self:wait(function() return "error" end, 250) end)
if ignore == nil then
ignore = self._default_attr_ignore
end
if attrs == nil then
attrs = {}
if self._default_attr_ids ~= nil then
for i, a in ipairs(self._default_attr_ids) do
attrs[i] = a
end
end
if ignore ~= true then
for i = 1, self._height do
local row = self._rows[i]
for j = 1, self._width do
local attr = row[j].attrs
if attr_index(attrs, attr) == nil and attr_index(ignore, attr) == nil then
if not equal_attrs(attr, {}) then
table.insert(attrs, attr)
end
end
end
end
end
end
local rv = {}
for i = 1, self._height do
table.insert(rv, " "..self:_row_repr(self._rows[i],attrs, ignore).."|")
end
local attrstrs = {}
local alldefault = true
for i, a in ipairs(attrs) do
if self._default_attr_ids == nil or self._default_attr_ids[i] ~= a then
alldefault = false
end
local dict = "{"..pprint_attrs(a).."}"
table.insert(attrstrs, "["..tostring(i).."] = "..dict)
end
local attrstr = "{"..table.concat(attrstrs, ", ").."}"
print( "\nscreen:expect([[")
print( table.concat(rv, '\n'))
if alldefault then
print( "]])\n")
else
print( "]], "..attrstr..")\n")
end
end
function pprint_attrs(attrs)
local items = {}
for f, v in pairs(attrs) do
local desc = tostring(v)
if f == "foreground" or f == "background" then
if colornames[v] ~= nil then
desc = "Screen.colors."..colornames[v]
end
end
table.insert(items, f.." = "..desc)
end
return table.concat(items, ", ")
end
function backward_find_meaningful(tbl, from)
for i = from or #tbl, 1, -1 do
if tbl[i] ~= ' ' then
return i + 1
end
end
return from
end
function get_attr_id(attr_ids, ignore, attrs)
if not attr_ids then
return
end
for id, a in pairs(attr_ids) do
if equal_attrs(a, attrs) then
return id
end
end
if equal_attrs(attrs, {}) or
ignore == true or attr_index(ignore, attrs) ~= nil then
-- ignore this attrs
return nil
end
return "UNEXPECTED "..pprint_attrs(attrs)
end
function equal_attrs(a, b)
return a.bold == b.bold and a.standout == b.standout and
a.underline == b.underline and a.undercurl == b.undercurl and
a.italic == b.italic and a.reverse == b.reverse and
a.foreground == b.foreground and
a.background == b.background
end
function attr_index(attrs, attr)
if not attrs then
return nil
end
for i,a in pairs(attrs) do
if equal_attrs(a, attr) then
return i
end
end
return nil
end
return Screen