diff options
-rw-r--r-- | test/functional/helpers.lua | 51 | ||||
-rw-r--r-- | test/functional/ui/highlight_spec.lua | 184 | ||||
-rw-r--r-- | test/functional/ui/input_spec.lua | 40 | ||||
-rw-r--r-- | test/functional/ui/screen.lua | 380 | ||||
-rw-r--r-- | test/functional/ui/screen_basic_spec.lua | 224 |
5 files changed, 851 insertions, 28 deletions
diff --git a/test/functional/helpers.lua b/test/functional/helpers.lua index 15adc3b1c3..fc699d22a3 100644 --- a/test/functional/helpers.lua +++ b/test/functional/helpers.lua @@ -47,12 +47,6 @@ local function request(method, ...) error(rv[2]) end end - -- Make sure this will only return after all buffered characters have been - -- processed - if not loop_stopped then - -- Except when the loop has been stopped by a notification triggered - -- by the initial request, for example. - end return rv end @@ -70,23 +64,30 @@ local function call_and_stop_on_error(...) return result end -local function run(request_cb, notification_cb, setup_cb) +local function run(request_cb, notification_cb, setup_cb, timeout) + local on_request, on_notification, on_setup - local function on_request(method, args) - return call_and_stop_on_error(request_cb, method, args) + if request_cb then + function on_request(method, args) + return call_and_stop_on_error(request_cb, method, args) + end end - local function on_notification(method, args) - call_and_stop_on_error(notification_cb, method, args) + if notification_cb then + function on_notification(method, args) + call_and_stop_on_error(notification_cb, method, args) + end end - local function on_setup() - call_and_stop_on_error(setup_cb) + if setup_cb then + function on_setup() + call_and_stop_on_error(setup_cb) + end end loop_stopped = false loop_running = true - session:run(on_request, on_notification, on_setup) + session:run(on_request, on_notification, on_setup, timeout) loop_running = false if last_error then local err = last_error @@ -115,15 +116,6 @@ local function nvim_feed(input) end end -local function nvim_replace_termcodes(input) - -- small hack to stop <C-@> from being replaced by the internal - -- representation(which is different and won't work for vim_input) - local temp_replacement = 'CCCCCCCCC@@@@@@@@@@' - input = input:gsub('<[Cc][-]@>', temp_replacement) - local rv = request('vim_replace_termcodes', input, false, true, true) - return rv:gsub(temp_replacement, '\000') -end - local function dedent(str) -- find minimum common indent across lines local indent = nil @@ -148,7 +140,7 @@ end local function feed(...) for _, v in ipairs({...}) do - nvim_feed(nvim_replace_termcodes(dedent(v))) + nvim_feed(dedent(v)) end end @@ -172,8 +164,11 @@ end local function insert(...) nvim_feed('i') - rawfeed(...) - nvim_feed(nvim_replace_termcodes('<ESC>')) + for _, v in ipairs({...}) do + local escaped = v:gsub('<', '<lt>') + rawfeed(escaped) + end + nvim_feed('<ESC>') end local function execute(...) @@ -182,8 +177,8 @@ local function execute(...) -- not a search command, prefix with colon nvim_feed(':') end - nvim_feed(v) - nvim_feed(nvim_replace_termcodes('<CR>')) + nvim_feed(v:gsub('<', '<lt>')) + nvim_feed('<CR>') end end diff --git a/test/functional/ui/highlight_spec.lua b/test/functional/ui/highlight_spec.lua new file mode 100644 index 0000000000..3c55c09f95 --- /dev/null +++ b/test/functional/ui/highlight_spec.lua @@ -0,0 +1,184 @@ +local helpers = require('test.functional.helpers') +local Screen = require('test.functional.ui.screen') +local clear, feed, nvim = helpers.clear, helpers.feed, helpers.nvim +local execute = helpers.execute + +describe('Default highlight groups', function() + -- Test the default attributes for highlight groups shown by the :highlight + -- command + local screen, hlgroup_colors + + setup(function() + hlgroup_colors = { + NonText = nvim('name_to_color', 'Blue'), + Question = nvim('name_to_color', 'SeaGreen') + } + end) + + before_each(function() + clear() + screen = Screen.new() + screen:attach() + end) + + after_each(function() + screen:detach() + end) + + it('window status bar', function() + screen:set_default_attr_ids({ + [1] = {reverse = true, bold = true}, -- StatusLine + [2] = {reverse = true} -- StatusLineNC + }) + execute('sp', 'vsp', 'vsp') + screen:expect([[ + ^ {2:|} {2:|} | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + {1:[No Name] }{2:[No Name] [No Name] }| + | + ~ | + ~ | + ~ | + ~ | + {2:[No Name] }| + | + ]]) + -- navigate to verify that the attributes are properly moved + feed('<c-w>j') + screen:expect([[ + {2:|} {2:|} | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + {2:[No Name] [No Name] [No Name] }| + ^ | + ~ | + ~ | + ~ | + ~ | + {1:[No Name] }| + | + ]]) + -- note that when moving to a window with small width nvim will increase + -- the width of the new active window at the expense of a inactive window + -- (upstream vim has the same behavior) + feed('<c-w>k<c-w>l') + screen:expect([[ + {2:|}^ {2:|} | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + {2:[No Name] }{1:[No Name] }{2:[No Name] }| + | + ~ | + ~ | + ~ | + ~ | + {2:[No Name] }| + | + ]]) + feed('<c-w>l') + screen:expect([[ + {2:|} {2:|}^ | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + {2:[No Name] [No Name] }{1:[No Name] }| + | + ~ | + ~ | + ~ | + ~ | + {2:[No Name] }| + | + ]]) + feed('<c-w>h<c-w>h') + screen:expect([[ + ^ {2:|} {2:|} | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + ~ {2:|}~ {2:|}~ | + {1:[No Name] }{2:[No Name] [No Name] }| + | + ~ | + ~ | + ~ | + ~ | + {2:[No Name] }| + | + ]]) + end) + + it('insert mode text', function() + feed('i') + screen:expect([[ + ^ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + {1:-- INSERT --} | + ]], {[1] = {bold = true}}) + end) + + it('end of file markers', function() + nvim('command', 'hi Normal guibg=black') + screen:expect([[ + ^ | + {1:~ }| + {1:~ }| + {1:~ }| + {1:~ }| + {1:~ }| + {1:~ }| + {1:~ }| + {1:~ }| + {1:~ }| + {1:~ }| + {1:~ }| + {1:~ }| + | + ]], {[1] = {bold = true, foreground = hlgroup_colors.NonText}}) + end) + + it('"wait return" text', function() + feed(':ls<cr>') + screen:expect([[ + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + :ls | + 1 %a "[No Name]" line 1 | + {1:Press ENTER or type command to continue}^ | + ]], {[1] = {bold = true, foreground = hlgroup_colors.Question}}) + feed('<cr>') -- skip the "Press ENTER..." state or tests will hang + end) +end) diff --git a/test/functional/ui/input_spec.lua b/test/functional/ui/input_spec.lua new file mode 100644 index 0000000000..60a49c4ed7 --- /dev/null +++ b/test/functional/ui/input_spec.lua @@ -0,0 +1,40 @@ +local helpers = require('test.functional.helpers') +local clear, execute, nvim = helpers.clear, helpers.execute, helpers.nvim +local feed, next_message, eq = helpers.feed, helpers.next_message, helpers.eq + +describe('mappings', function() + local cid + + local add_mapping = function(mapping, send) + local str = 'mapped '..mapping + local cmd = "nnoremap "..mapping.." :call rpcnotify("..cid..", 'mapped', '" + ..send:gsub('<', '<lt>').."')<cr>" + execute(cmd) + end + + local check_mapping = function(mapping, expected) + feed(mapping) + eq({'notification', 'mapped', {expected}}, next_message()) + end + + before_each(function() + clear() + cid = nvim('get_api_info')[1] + add_mapping('<s-up>', '<s-up>') + add_mapping('<s-up>', '<s-up>') + add_mapping('<c-s-up>', '<c-s-up>') + add_mapping('<c-s-a-up>', '<c-s-a-up>') + end) + + it('ok', function() + check_mapping('<s-up>', '<s-up>') + check_mapping('<c-s-up>', '<c-s-up>') + check_mapping('<s-c-up>', '<c-s-up>') + check_mapping('<c-s-a-up>', '<c-s-a-up>') + check_mapping('<s-c-a-up>', '<c-s-a-up>') + check_mapping('<c-a-s-up>', '<c-s-a-up>') + check_mapping('<s-a-c-up>', '<c-s-a-up>') + check_mapping('<a-c-s-up>', '<c-s-a-up>') + check_mapping('<a-s-c-up>', '<c-s-a-up>') + end) +end) 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 diff --git a/test/functional/ui/screen_basic_spec.lua b/test/functional/ui/screen_basic_spec.lua new file mode 100644 index 0000000000..a1110b3231 --- /dev/null +++ b/test/functional/ui/screen_basic_spec.lua @@ -0,0 +1,224 @@ +local helpers = require('test.functional.helpers') +local Screen = require('test.functional.ui.screen') +local clear, feed, execute = helpers.clear, helpers.feed, helpers.execute +local insert = helpers.insert + +describe('Screen', function() + local screen + + before_each(function() + clear() + screen = Screen.new() + screen:attach() + end) + + after_each(function() + screen:detach() + end) + + describe('window', function() + describe('split', function() + it('horizontal', function() + execute('sp') + screen:expect([[ + ^ | + ~ | + ~ | + ~ | + ~ | + ~ | + [No Name] | + | + ~ | + ~ | + ~ | + ~ | + [No Name] | + :sp | + ]]) + end) + + it('horizontal and resize', function() + execute('sp') + execute('resize 8') + screen:expect([[ + ^ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + [No Name] | + | + ~ | + ~ | + [No Name] | + :resize 8 | + ]]) + end) + + it('horizontal and vertical', function() + execute('sp', 'vsp', 'vsp') + screen:expect([[ + ^ | | | + ~ |~ |~ | + ~ |~ |~ | + ~ |~ |~ | + ~ |~ |~ | + ~ |~ |~ | + [No Name] [No Name] [No Name] | + | + ~ | + ~ | + ~ | + ~ | + [No Name] | + | + ]]) + insert('hello') + screen:expect([[ + hell^ |hello |hello | + ~ |~ |~ | + ~ |~ |~ | + ~ |~ |~ | + ~ |~ |~ | + ~ |~ |~ | + [No Name] [+] [No Name] [+] [No Name] [+] | + hello | + ~ | + ~ | + ~ | + ~ | + [No Name] [+] | + | + ]]) + end) + end) + end) + + describe('tabnew', function() + it('creates a new buffer', function() + execute('sp', 'vsp', 'vsp') + insert('hello') + screen:expect([[ + hell^ |hello |hello | + ~ |~ |~ | + ~ |~ |~ | + ~ |~ |~ | + ~ |~ |~ | + ~ |~ |~ | + [No Name] [+] [No Name] [+] [No Name] [+] | + hello | + ~ | + ~ | + ~ | + ~ | + [No Name] [+] | + | + ]]) + execute('tabnew') + insert('hello2') + feed('h') + screen:expect([[ + 4+ [No Name] + [No Name] X| + hell^2 | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + | + ]]) + execute('tabprevious') + screen:expect([[ + 4+ [No Name] + [No Name] X| + hell^ |hello |hello | + ~ |~ |~ | + ~ |~ |~ | + ~ |~ |~ | + ~ |~ |~ | + ~ |~ |~ | + [No Name] [+] [No Name] [+] [No Name] [+] | + hello | + ~ | + ~ | + ~ | + [No Name] [+] | + | + ]]) + end) + end) + + describe('insert mode', function() + it('move to next line with <cr>', function() + feed('iline 1<cr>line 2<cr>') + screen:expect([[ + line 1 | + line 2 | + ^ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + -- INSERT -- | + ]]) + end) + end) + + describe('command mode', function() + it('typing commands', function() + feed(':ls') + screen:expect([[ + | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + :ls^ | + ]]) + end) + + it('execute command with multi-line output', function() + feed(':ls<cr>') + screen:expect([[ + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + ~ | + :ls | + 1 %a "[No Name]" line 1 | + Press ENTER or type command to continue^ | + ]]) + feed('<cr>') -- skip the "Press ENTER..." state or tests will hang + end) + end) +end) |