aboutsummaryrefslogtreecommitdiff
path: root/test/functional/ui/screen.lua
diff options
context:
space:
mode:
Diffstat (limited to 'test/functional/ui/screen.lua')
-rw-r--r--test/functional/ui/screen.lua380
1 files changed, 380 insertions, 0 deletions
diff --git a/test/functional/ui/screen.lua b/test/functional/ui/screen.lua
new file mode 100644
index 0000000000..ff22321e4e
--- /dev/null
+++ b/test/functional/ui/screen.lua
@@ -0,0 +1,380 @@
+-- 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".
+--
+-- Too 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:
+--
+-- screen:expect([[
+-- hello screen \
+-- ~ \
+-- ~ \
+-- ~ \
+-- ~ \
+-- ~ \
+-- ~ \
+-- ~ \
+-- ~ \
+-- {b:-- INSERT --} \
+-- ]], {b = {bold = true}})
+--
+-- 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
+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
+
+function Screen.new(width, height)
+ if not width then
+ width = 53
+ end
+ if not height then
+ height = 14
+ end
+ return setmetatable({
+ _default_attr_ids = nil,
+ _width = width,
+ _height = height,
+ _rows = new_cell_grid(width, height),
+ _mode = 'normal',
+ _mouse_enabled = true,
+ _bell = false,
+ _visual_bell = false,
+ _suspended = true,
+ _attrs = {},
+ _cursor = {
+ enabled = true, row = 1, col = 1
+ },
+ _scroll_region = {
+ top = 1, bot = height, left = 1, right = width
+ }
+ }, Screen)
+end
+
+function Screen:set_default_attr_ids(attr_ids)
+ self._default_attr_ids = attr_ids
+end
+
+function Screen:attach()
+ request('attach_ui', self._width, self._height)
+ self._suspended = false
+end
+
+function Screen:detach()
+ request('detach_ui')
+ self._suspended = true
+end
+
+function Screen:expect(expected, attr_ids)
+ -- 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
+ 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)
+ 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
+ local function notification_cb(method, args)
+ assert(method == 'redraw')
+ self:_redraw(args)
+ err = check()
+ if not err then
+ stop()
+ end
+ return true
+ end
+ run(nil, notification_cb, nil, timeout or 5000)
+ if err then
+ error(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)
+ self._rows = new_cell_grid(width, height)
+end
+
+function Screen:_handle_clear()
+ self:_clear_block(1, self._height, 1, self._width)
+end
+
+function Screen:_handle_eol_clear()
+ local row, col = self._cursor.row, self._cursor.col
+ self:_clear_block(row, 1, col, self._width - col)
+end
+
+function Screen:_handle_cursor_goto(row, col)
+ self._cursor.row = row + 1
+ self._cursor.col = col + 1
+end
+
+function Screen:_handle_cursor_on()
+ self._cursor.enabled = true
+end
+
+function Screen:_handle_cursor_off()
+ self._cursor.enabled = 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]
+ self:_copy_row_section(target, source, left, right)
+ end
+
+ -- clear invalid rows
+ for i = stop + 1, 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_suspend()
+ self._suspended = true
+end
+
+function Screen:_clear_block(top, lines, left, columns)
+ for i = top, top + lines - 1 do
+ self:_clear_row_section(i, left, left + columns - 1)
+ 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:_copy_row_section(target, source, startcol, stopcol)
+ for i = startcol, stopcol do
+ target[i].text = source[i].text
+ target[i].attrs = source[i].attrs
+ end
+end
+
+function Screen:_row_repr(row, attr_ids)
+ local rv = {}
+ local current_attr_id
+ for i = 1, self._width do
+ local attr_id = get_attr_id(attr_ids, 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, '^')
+ else
+ table.insert(rv, row[i].text)
+ end
+ 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 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 new_cell_grid(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
+ return rows
+end
+
+function get_attr_id(attr_ids, attrs)
+ if not attr_ids then
+ return
+ end
+ for id, a in pairs(attr_ids) do
+ if a.bold == attrs.bold and a.standout == attrs.standout and
+ a.underline == attrs.underline and a.undercurl == attrs.undercurl and
+ a.italic == attrs.italic and a.reverse == attrs.reverse and
+ a.foreground == attrs.foreground and
+ a.background == attrs.background then
+ return id
+ end
+ end
+ return nil
+end
+
+return Screen