Files
nuwiki/development/tests/test-keymaps.lua
T
gffranco 21b485c91b Remove stale phase/spec references from code comments
Strip "Phase N", "SPEC §X.Y", and "v1.x" planning labels from comments
across the Rust crates and editor layers now that those tracking
artifacts are gone. Comments only — no logic, strings, or test names
touched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:25:51 -03:00

707 lines
23 KiB
Lua

-- development/tests/test-keymaps.lua — driven by development/tests/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)
-- ===== Documented command surface =====
-- Every command must be reachable in BOTH the canonical :Nuwiki* form
-- and the vimwiki-compatible :Vimwiki* form. The suffixes below are the
-- command list minus the prefix. :NuwikiInstall is the one exception — a
-- nuwiki-only maintenance command with no :Vimwiki* alias.
local both_forms = {
'Index', 'TabIndex', 'UISelect', 'Goto', 'FollowLink', 'Backlinks',
'NextLink', 'PrevLink', 'BaddLink', 'RenameFile', 'DeleteFile',
'MakeDiaryNote', 'MakeYesterdayDiaryNote', 'MakeTomorrowDiaryNote',
'DiaryIndex', 'DiaryGenerateLinks', 'DiaryNextDay', 'DiaryPrevDay',
'TOC', 'GenerateLinks', 'CheckLinks', 'SearchTags', 'GenerateTagLinks',
'RebuildTags', '2HTML', '2HTMLBrowse', 'All2HTML', 'Rss',
}
for _, suffix in ipairs(both_forms) do
for _, prefix in ipairs({ 'Nuwiki', 'Vimwiki' }) do
local cmd = prefix .. suffix
local exists = vim.fn.exists(':' .. cmd) == 2
record(exists, 'surface.' .. cmd, exists and '' or (':' .. cmd .. ' not registered'))
end
end
do
local exists = vim.fn.exists(':NuwikiInstall') == 2
record(exists, 'surface.NuwikiInstall',
exists and '' or ':NuwikiInstall not registered')
end
-- ===== Pure-Lua command invocation (cursor movement) =====
-- :NuwikiNextLink / :NuwikiPrevLink wrap the link-search motion and run
-- without the LSP, so we can assert exact cursor movement here.
do
local ok, err = pcall(function()
set_buf({ 'intro [[A]] more [[B]] end' })
vim.api.nvim_win_set_cursor(0, { 1, 0 })
vim.cmd('NuwikiNextLink')
local c1 = vim.api.nvim_win_get_cursor(0)[2]
if c1 ~= 6 then error('NextLink #1 col=' .. c1 .. ' want 6') end
vim.cmd('NuwikiNextLink')
local c2 = vim.api.nvim_win_get_cursor(0)[2]
if c2 ~= 17 then error('NextLink #2 col=' .. c2 .. ' want 17') end
vim.cmd('VimwikiPrevLink')
local c3 = vim.api.nvim_win_get_cursor(0)[2]
if c3 ~= 6 then error('PrevLink col=' .. c3 .. ' want 6') end
end)
record(ok, 'invoke.next_prev_link_cursor_movement', ok and '' or tostring(err))
end
-- ===== Documented mapping surface =====
-- Every documented mapping must resolve to a buffer-local mapping in
-- the mode(s) the doc lists it under. `modes` is a string of mode
-- letters: n/x/o/i. The mouse group is opt-in — the harness enables it
-- via setup({ mappings = { mouse = true } }).
local function has_map(lhs, mode)
local d = vim.fn.maparg(lhs, mode, false, true)
return next(d) ~= nil and d.buffer == 1
end
local mapping_surface = {
-- Links
{ '<CR>', 'n' }, { '<S-CR>', 'n' }, { '<C-CR>', 'n' }, { '<C-S-CR>', 'n' },
{ '<BS>', 'n' }, { '<Tab>', 'n' }, { '<S-Tab>', 'n' }, { '+', 'nx' },
-- Lists
{ '<C-Space>', 'nx' }, { '<C-@>', 'nx' }, { '<Nul>', 'nx' },
{ 'gnt', 'n' }, { 'gln', 'nx' }, { 'glp', 'nx' }, { 'glx', 'nx' },
{ 'glh', 'n' }, { 'gll', 'n' }, { 'gLh', 'n' }, { 'gLl', 'n' },
{ 'glr', 'n' }, { 'gLr', 'n' }, { 'gl<Space>', 'n' }, { 'gL<Space>', 'n' },
{ 'o', 'n' }, { 'O', 'n' },
-- Headers
{ '=', 'n' }, { '-', 'n' }, { ']]', 'n' }, { '[[', 'n' },
{ ']=', 'n' }, { '[=', 'n' }, { ']u', 'n' }, { '[u', 'n' },
-- Tables
{ 'gqq', 'n' }, { 'gq1', 'n' }, { 'gww', 'n' }, { 'gw1', 'n' },
{ '<A-Left>', 'n' }, { '<A-Right>', 'n' },
-- Wiki / diary / export
{ '<Leader>ww', 'n' }, { '<Leader>ws', 'n' }, { '<Leader>wi', 'n' },
{ '<Leader>w<Leader>w', 'n' }, { '<Leader>w<Leader>y', 'n' },
{ '<Leader>w<Leader>t', 'n' }, { '<Leader>w<Leader>i', 'n' },
{ '<Leader>wn', 'n' }, { '<Leader>wd', 'n' }, { '<Leader>wr', 'n' },
{ '<Leader>wh', 'n' }, { '<Leader>whh', 'n' }, { '<Leader>wha', 'n' },
{ '<Leader>wc', 'nx' }, { '<C-Down>', 'n' }, { '<C-Up>', 'n' },
-- Mouse (opt-in)
{ '<2-LeftMouse>', 'n' }, { '<S-2-LeftMouse>', 'n' }, { '<C-2-LeftMouse>', 'n' },
{ '<MiddleMouse>', 'n' }, { '<RightMouse>', 'n' },
-- Text objects (operator-pending + visual)
{ 'ah', 'ox' }, { 'ih', 'ox' }, { 'aH', 'ox' }, { 'iH', 'ox' },
{ 'al', 'ox' }, { 'il', 'ox' }, { 'a\\', 'ox' }, { 'i\\', 'ox' },
{ 'ac', 'ox' }, { 'ic', 'ox' },
-- Insert-mode
{ '<CR>', 'i' }, { '<Tab>', 'i' }, { '<S-Tab>', 'i' },
{ '<C-D>', 'i' }, { '<C-T>', 'i' },
{ '<C-L><C-J>', 'i' }, { '<C-L><C-K>', 'i' }, { '<C-L><C-M>', 'i' },
}
for _, entry in ipairs(mapping_surface) do
local lhs, modes = entry[1], entry[2]
for m in modes:gmatch('.') do
local ok = has_map(lhs, m)
record(ok, 'map[' .. m .. '].' .. lhs,
ok and '' or (lhs .. ' (' .. m .. ') not mapped buffer-local'))
end
end
-- ===== 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,
})
-- `glp` must cycle BACKWARD (regression for the glp==gln bug). From
-- `[.]` the backward step lands on `[ ]`, whereas `gln` would advance
-- to `[o]`.
run('list.cycle_back_via_glp', {
lines = { '- [.] task' },
cursor = { 1, 0 },
keys = 'glp',
expect_line = '- [ ] task',
expect_at = 1,
})
run('list.cycle_back_via_glp_wraps', {
-- `[ ]` backward wraps to the top of the progression (`[X]`).
lines = { '- [ ] task' },
cursor = { 1, 0 },
keys = 'glp',
expect_line = '- [X] task',
expect_at = 1,
})
-- ===== Change level (glh/gll) =====
run('list.indent_via_gll', {
lines = { '- item' },
cursor = { 1, 0 },
keys = 'gll',
expect_line = ' - item',
expect_at = 1,
})
run('list.dedent_via_glh', {
lines = { ' - item' },
cursor = { 1, 2 },
keys = 'glh',
expect_line = '- item',
expect_at = 1,
})
-- ===== Renumber (glr / gLr) =====
run('list.renumber_via_glr', {
lines = { '5. one', '9. two', '2. three' },
cursor = { 1, 0 },
keys = 'glr',
expect_lines = { '1. one', '2. two', '3. three' },
})
-- ===== Remove done (gl<Space> current list / gL<Space> whole doc) =====
-- `gl<Space>` removes done items from the cursor's list only; the
-- second list's done item survives.
run('list.remove_done_current_list_via_gl_space', {
lines = {
'- [X] done a', '- [ ] todo a', '',
'paragraph', '',
'- [ ] todo b', '- [X] done b',
},
cursor = { 1, 0 },
keys = 'gl<Space>',
expect_lines = {
'- [ ] todo a', '',
'paragraph', '',
'- [ ] todo b', '- [X] done b',
},
})
-- `gL<Space>` sweeps the whole buffer regardless of cursor position.
run('list.remove_done_whole_buffer_via_gL_space', {
lines = {
'- [X] done a', '- [ ] todo a', '',
'paragraph', '',
'- [ ] todo b', '- [X] done b',
},
cursor = { 1, 0 },
keys = 'gL<Space>',
expect_lines = {
'- [ ] todo a', '',
'paragraph', '',
'- [ ] todo b',
},
})
-- ===== 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 (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.
-- ===== Wiki picker (config-driven, no LSP) =====
-- The picker must build its list from config so it works from any buffer
-- before the server attaches.
tobj_case('wiki_select.config_list_reads_g_var', function()
vim.g.nuwiki_wikis = {
{ name = 'Personal', root = '/tmp/nuwiki-a' },
{ name = 'Work', root = '/tmp/nuwiki-b', file_extension = 'md' },
}
local list = require('nuwiki.config').wiki_list()
vim.g.nuwiki_wikis = nil
if #list ~= 2 then error('want 2 wikis, got ' .. #list) end
if list[1].name ~= 'Personal' or list[2].name ~= 'Work' then
error('names/order broken: ' .. vim.inspect(list))
end
if list[2].ext ~= '.md' then
error('per-wiki extension not honoured: ' .. tostring(list[2].ext))
end
end)
-- ===== 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)