Files
nuwiki/scripts/test-keymaps.lua
T
gffranco 0dfe0910fc fix(tables): cycle Tab into the next row instead of inserting one
Pressing <Tab> past the last cell unconditionally appended a new row
below, even when there was already a next row to jump into. Tabbing
through a header → separator → body table from the header row
inserted a blank row between header and separator instead of landing
in the first body cell.

After the final cell, walk forward from the current row to the end
of the table, skip any separator row, and jump to the first cell of
the next data row. Only insert a fresh row when the cursor is on the
table's last row (or only separator rows follow). Applied to both
the Vim (autoload/nuwiki/commands.vim) and Neovim (lua/nuwiki/
commands.lua) sides so the two harnesses stay in sync; covered by
new keymap tests in scripts/test-keymaps-vim.vim and test-keymaps.lua.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:55:40 -03:00

511 lines
16 KiB
Lua

-- scripts/test-keymaps.lua — driven by scripts/test-keymaps.sh.
--
-- Headless Neovim harness for the buffer-local keymaps registered by
-- `ftplugin/vimwiki.vim` + `lua/nuwiki/keymaps.lua`. Each case sets up
-- the buffer, places the cursor, fires the key sequence via
-- `nvim_feedkeys`, waits for any LSP round-trip, and asserts the
-- expected post-state.
--
-- Pass/fail summary is written to the path passed as argv[1] so the
-- shell wrapper can read + render it without trying to capture nvim's
-- TUI output.
local OUT = vim.env.NUWIKI_KEYMAP_RESULTS or '/tmp/nuwiki-keymap-results.txt'
local out_lines = {}
local pass = 0
local fail = 0
local function record(ok, name, detail)
local suffix = (detail and detail ~= '') and ('' .. detail) or ''
table.insert(out_lines, (ok and 'PASS' or 'FAIL') .. ' ' .. name .. suffix)
if ok then pass = pass + 1 else fail = fail + 1 end
end
local function set_buf(lines)
vim.api.nvim_buf_set_lines(0, 0, -1, false, lines)
end
local function get_lines()
return vim.api.nvim_buf_get_lines(0, 0, -1, false)
end
local function get_line(n)
return vim.api.nvim_buf_get_lines(0, n - 1, n, false)[1] or ''
end
local function send(keys)
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, false, true), 'x', false)
end
local function sleep(ms)
vim.cmd('sleep ' .. ms .. 'm')
end
--- Run one case: lines + cursor (1-based) + key sequence + expected
--- post-state. `wait_ms` defaults to 800 (enough for an LSP
--- executeCommand round-trip on a hot server).
local function run(name, opts)
local ok, err = pcall(function()
set_buf(opts.lines)
vim.api.nvim_win_set_cursor(0, opts.cursor or { 1, 0 })
send(opts.keys)
sleep(opts.wait_ms or 800)
local actual_lines = get_lines()
if opts.expect_lines then
if not vim.deep_equal(actual_lines, opts.expect_lines) then
error(string.format('lines mismatch\n expected: %s\n actual: %s',
vim.inspect(opts.expect_lines), vim.inspect(actual_lines)))
end
end
if opts.expect_line and opts.expect_at then
local got = get_line(opts.expect_at)
if got ~= opts.expect_line then
error(string.format('line %d mismatch\n expected: %q\n actual: %q',
opts.expect_at, opts.expect_line, got))
end
end
if opts.expect_cursor_line then
local lnum = vim.api.nvim_win_get_cursor(0)[1]
if lnum ~= opts.expect_cursor_line then
error(string.format('cursor line mismatch — expected %d, got %d',
opts.expect_cursor_line, lnum))
end
end
if opts.expect_buf_path then
local got = vim.api.nvim_buf_get_name(0)
if not got:find(opts.expect_buf_path, 1, true) then
error(string.format('buffer path mismatch\n expected substring: %q\n actual: %q',
opts.expect_buf_path, got))
end
end
end)
record(ok, name, ok and '' or tostring(err))
end
local function get_lsp_clients(opts)
local fn = vim.lsp.get_clients or vim.lsp.get_active_clients
if not fn then return {} end
return fn(opts)
end
local function wait_for_lsp(timeout_ms)
local deadline = vim.loop.now() + (timeout_ms or 5000)
while vim.loop.now() < deadline do
local clients = get_lsp_clients({ name = 'nuwiki', bufnr = 0 })
if #clients > 0 and clients[1].server_capabilities
and clients[1].server_capabilities.executeCommandProvider then
return clients[1]
end
sleep(100)
end
return nil
end
-- ===== Capture server log_message events =====
local server_log = {}
local orig_handler = vim.lsp.handlers['window/logMessage']
vim.lsp.handlers['window/logMessage'] = function(err, result, ctx, cfg)
if result and result.message then
table.insert(server_log, '[' .. tostring(result.type) .. '] ' .. result.message)
end
if orig_handler then return orig_handler(err, result, ctx, cfg) end
end
-- ===== Smoke: LSP attached =====
vim.defer_fn(function()
local client = wait_for_lsp()
if not client then
record(false, 'lsp.attached', 'no nuwiki client after 5s')
vim.fn.writefile(out_lines, OUT)
vim.cmd('qall!')
return
end
record(true, 'lsp.attached', 'client=' .. client.name)
-- ===== Lists =====
run('list.toggle_via_C-Space', {
lines = { '= test =', '- [ ] task' },
cursor = { 2, 0 },
keys = '<C-Space>',
expect_line = '- [X] task',
expect_at = 2,
})
run('list.toggle_via_C-@', {
lines = { '= test =', '- [ ] task' },
cursor = { 2, 0 },
keys = '<C-@>',
expect_line = '- [X] task',
expect_at = 2,
})
run('list.toggle_via_Nul', {
lines = { '= test =', '- [ ] task' },
cursor = { 2, 0 },
keys = '<Nul>',
expect_line = '- [X] task',
expect_at = 2,
})
run('list.cycle_via_gln', {
lines = { '- [ ] task' },
cursor = { 1, 0 },
keys = 'gln',
expect_line = '- [.] task',
expect_at = 1,
})
run('list.reject_via_glx', {
lines = { '- [ ] task' },
cursor = { 1, 0 },
keys = 'glx',
expect_line = '- [-] task',
expect_at = 1,
})
run('list.toggle_round_trip', {
lines = { '- [X] done' },
cursor = { 1, 0 },
keys = '<C-Space>',
expect_line = '- [ ] done',
expect_at = 1,
})
-- ===== Next task (gnt) =====
run('list.gnt_jumps_to_unfinished', {
lines = { '- [X] done', '- [ ] todo', '- [ ] later' },
cursor = { 1, 0 },
keys = 'gnt',
expect_cursor_line = 2,
wait_ms = 600,
})
-- ===== Heading levels =====
run('heading.add_level_with_=', {
lines = { '= H1 =' },
cursor = { 1, 0 },
keys = '=',
expect_line = '== H1 ==',
expect_at = 1,
})
run('heading.remove_level_with_-', {
lines = { '== H2 ==' },
cursor = { 1, 0 },
keys = '-',
expect_line = '= H2 =',
expect_at = 1,
})
run('heading.add_level_clamps_at_6', {
lines = { '====== H6 ======' },
cursor = { 1, 0 },
keys = '=',
expect_line = '====== H6 ======', -- unchanged
expect_at = 1,
})
-- ===== Header nav (pure Lua, no LSP) =====
run('headers.]]_jumps_to_next', {
lines = { '= One =', 'body', '== Two ==', 'body', '= Three =' },
cursor = { 1, 0 },
keys = ']]',
expect_cursor_line = 3,
wait_ms = 100,
})
run('headers.[[_jumps_to_prev', {
lines = { '= One =', 'body', '== Two ==', 'body', '= Three =' },
cursor = { 5, 0 },
keys = '[[',
expect_cursor_line = 3,
wait_ms = 100,
})
run('headers.]u_jumps_to_parent', {
lines = { '= Parent =', '== Child ==', 'body' },
cursor = { 3, 0 },
keys = ']u',
expect_cursor_line = 1,
wait_ms = 100,
})
-- ===== Link nav (pure Lua) =====
run('links.<Tab>_jumps_to_next_wikilink', {
lines = { 'intro [[A]] more [[B]] end' },
cursor = { 1, 0 },
keys = '<Tab>',
expect_cursor_line = 1,
wait_ms = 100,
})
-- ===== <CR> two-step =====
run('cr.wraps_bare_word_first_press', {
lines = { 'MyPage rest' },
cursor = { 1, 3 },
keys = '<CR>',
expect_line = '[[MyPage]] rest',
expect_at = 1,
wait_ms = 200,
})
-- ===== Link helpers (§13.1 Cluster C) =====
run('links.normalize_via_+', {
lines = { 'MyPage rest' },
cursor = { 1, 3 },
keys = '+',
expect_line = '[[MyPage]] rest',
expect_at = 1,
wait_ms = 200,
})
-- ===== Bullet continuation (o/O) =====
run('lists.o_continues_list_marker', {
lines = { '- first' },
cursor = { 1, 0 },
keys = 'o<Esc>',
expect_lines = { '- first', '- ' },
wait_ms = 100,
})
run('lists.o_continues_checkbox', {
lines = { '- [ ] first' },
cursor = { 1, 0 },
keys = 'o<Esc>',
expect_lines = { '- [ ] first', '- [ ] ' },
wait_ms = 100,
})
run('lists.O_continues_above', {
lines = { '- second' },
cursor = { 1, 0 },
keys = 'O<Esc>',
expect_lines = { '- ', '- second' },
wait_ms = 100,
})
-- ===== Insert-mode list bindings (Cluster 2) =====
run('insert.C-L_C-M_adds_checkbox', {
lines = { '- task' },
cursor = { 1, 0 },
keys = 'A<C-L><C-M><Esc>',
expect_line = '- [ ] task',
expect_at = 1,
wait_ms = 200,
})
run('insert.C-L_C-M_toggles_existing_checkbox', {
lines = { '- [ ] task' },
cursor = { 1, 0 },
keys = 'A<C-L><C-M><Esc>',
expect_line = '- [X] task',
expect_at = 1,
})
-- End-to-end regression for changeSymbol over LSP — direct call
-- (no keymap) so we can spot a stale `bin/nuwiki-ls` or a dispatch
-- error fast. Toggle has separate coverage above via `<C-Space>`.
do
local ok, err = pcall(function()
set_buf({ '- [ ] item', '- [ ] two' })
sleep(200)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
require('nuwiki.commands').list_change_symbol('*', false)
sleep(1500)
local got = get_line(1)
if got ~= '* [ ] item' then
error(string.format('change_symbol failed: got %q', got))
end
end)
record(ok, 'direct.list_change_symbol_via_lsp', ok and '' or tostring(err))
end
run('insert.C-L_C-J_cycles_marker_forward', {
lines = { '- item' },
cursor = { 1, 0 },
keys = 'A<C-L><C-J><Esc>',
expect_line = '* item',
expect_at = 1,
wait_ms = 1500,
})
run('insert.C-L_C-K_cycles_marker_backward', {
-- From `-` going backward wraps to the end of the order (`I)`).
lines = { '- item' },
cursor = { 1, 0 },
keys = 'A<C-L><C-K><Esc>',
expect_line = 'I) item',
expect_at = 1,
wait_ms = 1500,
})
-- ===== Text objects (Cluster 3) =====
-- Exercise the pure helpers directly (cell ranges, heading block,
-- table bounds). The visual-mode integration is verified in a single
-- end-to-end case via mark inspection, which exercises the full
-- operator-pending path.
local function tobj_case(name, fn)
local ok, err = pcall(fn)
record(ok, name, ok and '' or tostring(err))
end
tobj_case('textobj.heading_block_section_only', function()
set_buf({ '= h1 =', 'a', '== h2 ==', 'b', '== h3 ==', 'c' })
local s, e = require('nuwiki.textobjects')._heading_block(1, false)
if not (s == 1 and e == 2) then
error(string.format('section_only: got (%s,%s) want (1,2)', s, e))
end
end)
tobj_case('textobj.heading_block_subtree', function()
set_buf({ '= h1 =', 'a', '== h2 ==', 'b', '== h3 ==', 'c' })
local s, e = require('nuwiki.textobjects')._heading_block(1, true)
if not (s == 1 and e == 6) then
error(string.format('subtree: got (%s,%s) want (1,6)', s, e))
end
end)
tobj_case('textobj.cell_ranges_three_cells', function()
local r = require('nuwiki.textobjects')._cell_ranges('| a | b | c |')
if #r ~= 3 then error('want 3 cells, got ' .. #r) end
-- Inside each cell, content includes the surrounding spaces.
if not (r[1][1] == 2 and r[2][1] > r[1][2] and r[3][1] > r[2][2]) then
error('cell ordering broken: ' .. vim.inspect(r))
end
end)
tobj_case('textobj.current_cell_picks_middle', function()
-- `| a | b | c |` cursor on the `b` (1-based col 8).
local idx = require('nuwiki.textobjects')._current_cell_for_line(
'| a | b | c |', 8)
if idx ~= 2 then error('expected cell 2, got ' .. tostring(idx)) end
end)
tobj_case('textobj.table_bounds_walks_contig_block', function()
set_buf({ 'before', '| a | b |', '| c | d |', '', 'after' })
vim.api.nvim_win_set_cursor(0, { 2, 0 })
local s, e = require('nuwiki.textobjects')._table_bounds(2)
if not (s == 2 and e == 3) then
error(string.format('want (2,3) got (%s,%s)', s, e))
end
end)
-- One end-to-end: operator-pending `dah` should delete the heading
-- section. This avoids the visual-mode `'<`/`'>` mark dance and
-- relies on Vim's actual operator pipeline to verify the keymap is
-- correctly wired into `omap`.
tobj_case('textobj.dah_deletes_heading_section', function()
set_buf({ '= h1 =', 'body', '= h2 =', 'rest' })
vim.api.nvim_win_set_cursor(0, { 1, 0 })
send('dah')
sleep(100)
local got = get_lines()
-- After deleting heading section (lines 1-2), buffer should be the
-- remaining two lines. There may be a trailing empty line depending
-- on linewise delete semantics; allow both shapes.
if not (vim.deep_equal(got, { '= h2 =', 'rest' })
or vim.deep_equal(got, { '', '= h2 =', 'rest' })) then
error('dah result: ' .. vim.inspect(got))
end
end)
-- ===== Smart <CR> (Cluster 5) =====
run('cr.continues_list_marker', {
lines = { '- first' },
cursor = { 1, 7 }, -- end of line
keys = 'A<CR>second<Esc>',
expect_lines = { '- first', '- second' },
wait_ms = 100,
})
run('cr.continues_checkbox', {
lines = { '- [ ] first' },
cursor = { 1, 11 },
keys = 'A<CR>second<Esc>',
expect_lines = { '- [ ] first', '- [ ] second' },
wait_ms = 100,
})
run('cr.breaks_on_empty_list_line', {
lines = { '- ' },
cursor = { 1, 2 },
keys = 'A<CR>plain<Esc>',
expect_lines = { '', 'plain' },
wait_ms = 100,
})
run('cr.adds_new_table_row', {
lines = { '| a | b |' },
cursor = { 1, 4 }, -- inside `a`
keys = 'A<CR>x<Esc>',
-- New row inserted below with empty cells; cursor jumps to first.
expect_lines = { '| a | b |', '|x | |' },
wait_ms = 100,
})
-- ===== Insert-mode table navigation (Cluster 6) =====
run('tab.next_cell_in_table', {
lines = { '| a | b |' },
cursor = { 1, 2 }, -- inside cell `a`
keys = 'A<Tab>x<Esc>',
-- After A: cursor at end. <Tab>: no cell-start strictly after end
-- so creates new row. Try cursor on inside-first-cell case below.
-- Here cursor at end has no next cell so a new row is added.
expect_lines = { '| a | b |', '|x | |' },
wait_ms = 100,
})
run('tab.from_first_cell_moves_to_second', {
lines = { '| a | b |' },
cursor = { 1, 0 },
keys = 'lli<Tab>x<Esc>',
-- `ll` from col 0 → col 2 (on `a`). `i` enters insert. `<Tab>`
-- jumps to col after next `|`. `x` types in second cell.
expect_lines = { '| a |x b |' },
wait_ms = 100,
})
run('tab.cycles_to_next_row_when_not_last', {
lines = { '| a | b |', '|---|---|', '| c | d |' },
cursor = { 1, 6 }, -- inside last cell of header row
keys = 'i<Tab>x<Esc>',
-- Past the final cell of row 1: skip separator, land in first cell of row 3.
expect_lines = { '| a | b |', '|---|---|', '|x c | d |' },
wait_ms = 100,
})
run('tab.inserts_row_only_when_on_last_row', {
lines = { '| a | b |', '|---|---|', '| c | d |' },
cursor = { 3, 6 }, -- inside last cell of last data row
keys = 'i<Tab>x<Esc>',
expect_lines = { '| a | b |', '|---|---|', '| c | d |', '|x | |' },
wait_ms = 100,
})
run('shift_tab.prev_cell_in_table', {
lines = { '| a | b |' },
cursor = { 1, 6 }, -- inside cell `b`
keys = 'i<S-Tab>x<Esc>',
-- Cursor lands just after the previous cell's `|`; typing `x`
-- inserts at col 2 (before the leading space inside cell 1).
expect_lines = { '|x a | b |' },
wait_ms = 100,
})
run('tab.outside_table_passes_through', {
lines = { 'plain text' },
cursor = { 1, 0 },
keys = 'i<Tab>x<Esc>',
-- Default Tab inserts a literal tab.
expect_lines = { '\tx' .. 'plain text' },
wait_ms = 100,
})
-- ===== Diary nav =====
-- Tested via the LSP open-* responses; can't fully validate without
-- a populated diary, but at least confirm the maps fire without
-- error.
-- ===== Wrap up =====
table.insert(out_lines, '')
-- Dump captured server log_messages only on failure — useful for
-- diagnosing dispatch errors that the LSP swallows into log_message.
if fail > 0 and #server_log > 0 then
table.insert(out_lines, '--- server log_messages ---')
for _, l in ipairs(server_log) do
table.insert(out_lines, l)
end
table.insert(out_lines, '')
end
table.insert(out_lines, string.format('SUMMARY: %d passed, %d failed', pass, fail))
vim.fn.writefile(out_lines, OUT)
vim.cmd(fail == 0 and 'qall!' or 'cquit!')
end, 1500)