0dfe0910fc
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>
511 lines
16 KiB
Lua
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)
|