66bfd61a63
The Neovim command defs for :VimwikiColorize / :NuwikiColorize were -nargs=1 but called colorize() without <q-args>, so the colour name was silently dropped and the user was prompted instead (the Vim branch passed it correctly). Pass <q-args>. While verifying, found a second bug in the Lua colorize(): it inferred visual-vs-normal from vim.fn.visualmode(), which returns the *last* visual mode used in the session along with stale '< / '> marks. So running :VimwikiColorize in normal mode after any earlier visual selection wrapped that leftover selection instead of the word under the cursor (produced an empty, misplaced span). colorize(color, visual) now takes an explicit visual flag, passed only by the x-mode <Leader>wc mapping; the command and the normal-mode mapping always wrap the cword. Tests: cmd.VimwikiColorize_uses_arg, cmd.NuwikiColorize_uses_arg, cmd.Colorize_ignores_stale_visual_selection, colorize.visual_wraps_selection in test-keymaps.lua. nvim suite 267, vim suite 254+18, all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
800 lines
27 KiB
Lua
800 lines
27 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',
|
|
'SplitLink', 'VSplitLink', 'TabnewLink', 'TabDropLink', 'GoBackLink',
|
|
'NextLink', 'PrevLink', 'BaddLink', 'RenameFile', 'DeleteFile',
|
|
'MakeDiaryNote', 'TabMakeDiaryNote', 'MakeYesterdayDiaryNote',
|
|
'MakeTomorrowDiaryNote',
|
|
'DiaryIndex', 'DiaryGenerateLinks', 'DiaryNextDay', 'DiaryPrevDay',
|
|
'TOC', 'GenerateLinks', 'CheckLinks', 'SearchTags', 'GenerateTagLinks',
|
|
'RebuildTags', '2HTML', '2HTMLBrowse', 'All2HTML', 'Rss',
|
|
'NormalizeLink', 'RenumberList', 'RenumberAllLists',
|
|
'ListToggle', 'IncrementListItem', 'DecrementListItem',
|
|
'CatUrl',
|
|
}
|
|
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
|
|
-- Asymmetric / compat-only spellings: the canonical :Nuwiki* name differs
|
|
-- from the upstream :Vimwiki* name (or has no canonical counterpart because
|
|
-- nuwiki already exposes it under a clearer name).
|
|
local extra_surface = {
|
|
'NuwikiTableAlign', 'VimwikiTableAlignQ', 'VimwikiTableAlignW',
|
|
'NuwikiChangeSymbol', 'VimwikiChangeSymbolTo', 'VimwikiListChangeSymbolI',
|
|
'NuwikiChangeSymbolInList', 'VimwikiChangeSymbolInListTo',
|
|
'VimwikiDeleteLink', 'VimwikiRenameLink', 'VimwikiGenerateTags',
|
|
'NuwikiRemoveCheckbox', 'VimwikiRemoveSingleCB',
|
|
'NuwikiRemoveCheckboxInList', 'VimwikiRemoveCBInList',
|
|
}
|
|
for _, cmd in ipairs(extra_surface) do
|
|
local exists = vim.fn.exists(':' .. cmd) == 2
|
|
record(exists, 'surface.' .. cmd, exists and '' or (':' .. cmd .. ' not registered'))
|
|
end
|
|
do
|
|
local callable = type(require('nuwiki.commands').follow_link_drop) == 'function'
|
|
record(callable, 'direct.follow_link_drop_callable',
|
|
callable and '' or 'nuwiki.commands.follow_link_drop is not a function')
|
|
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', 'n' }, { 'gL', 'n' },
|
|
{ 'gl-', 'n' }, { 'gL-', 'n' }, { 'gl*', 'n' }, { 'gL*', 'n' },
|
|
{ 'gl#', 'n' }, { 'gL#', 'n' }, { 'gl1', 'n' }, { 'gL1', 'n' },
|
|
{ 'gli', 'n' }, { 'gLi', 'n' }, { 'glI', 'n' }, { 'gLI', 'n' },
|
|
{ 'gla', 'n' }, { 'gLa', 'n' }, { 'glA', 'n' }, { 'gLA', '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 checkbox (bare gl item / gL whole list) =====
|
|
|
|
-- Bare `gl` strips the checkbox from the cursor's item, keeping the text.
|
|
run('list.remove_checkbox_item_via_gl', {
|
|
lines = { '= test =', '- [ ] task', '- [X] done' },
|
|
cursor = { 2, 0 },
|
|
keys = 'gl',
|
|
expect_line = '- task',
|
|
expect_at = 2,
|
|
})
|
|
-- Bare `gL` strips the checkbox from every item in the cursor's list.
|
|
run('list.remove_checkbox_list_via_gL', {
|
|
lines = { '- [ ] a', '- [X] b', '- [ ] c' },
|
|
cursor = { 1, 0 },
|
|
keys = 'gL',
|
|
expect_lines = { '- a', '- b', '- c' },
|
|
})
|
|
|
|
-- ===== Remove done (command-only: :NuwikiRemoveDone[!]) =====
|
|
|
|
-- `:NuwikiRemoveDone` removes done items from the cursor's list only; the
|
|
-- second list's done item survives.
|
|
run('list.remove_done_current_list_via_command', {
|
|
lines = {
|
|
'- [X] done a', '- [ ] todo a', '',
|
|
'paragraph', '',
|
|
'- [ ] todo b', '- [X] done b',
|
|
},
|
|
cursor = { 1, 0 },
|
|
keys = ':NuwikiRemoveDone<CR>',
|
|
expect_lines = {
|
|
'- [ ] todo a', '',
|
|
'paragraph', '',
|
|
'- [ ] todo b', '- [X] done b',
|
|
},
|
|
})
|
|
-- `:NuwikiRemoveDone!` sweeps the whole buffer regardless of cursor position.
|
|
run('list.remove_done_whole_buffer_via_command_bang', {
|
|
lines = {
|
|
'- [X] done a', '- [ ] todo a', '',
|
|
'paragraph', '',
|
|
'- [ ] todo b', '- [X] done b',
|
|
},
|
|
cursor = { 1, 0 },
|
|
keys = ':NuwikiRemoveDone!<CR>',
|
|
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.
|
|
|
|
-- ===== Colorize commands pass their {color} arg (regression) =====
|
|
-- :VimwikiColorize / :NuwikiColorize are -nargs=1; the Neovim defs used to
|
|
-- call colorize() without <q-args>, silently dropping the colour and
|
|
-- prompting instead. Run the real commands and assert the colour lands.
|
|
tobj_case('cmd.VimwikiColorize_uses_arg', function()
|
|
set_buf({ 'word here' })
|
|
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
|
vim.cmd('VimwikiColorize red')
|
|
local got = vim.api.nvim_get_current_line()
|
|
if got ~= '<span style="color:red">word</span> here' then
|
|
error('VimwikiColorize dropped its arg: ' .. got)
|
|
end
|
|
end)
|
|
tobj_case('cmd.NuwikiColorize_uses_arg', function()
|
|
set_buf({ 'second word' })
|
|
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
|
vim.cmd('NuwikiColorize #00ff00')
|
|
local got = vim.api.nvim_get_current_line()
|
|
if got ~= '<span style="color:#00ff00">second</span> word' then
|
|
error('NuwikiColorize dropped its arg: ' .. got)
|
|
end
|
|
end)
|
|
-- Establish a stale visual selection, then confirm the normal-mode command
|
|
-- still wraps the cword (not the leftover selection) — the visualmode()
|
|
-- heuristic regression.
|
|
tobj_case('cmd.Colorize_ignores_stale_visual_selection', function()
|
|
set_buf({ 'alpha beta gamma' })
|
|
vim.cmd('normal! 0vee\27') -- select 'alpha beta', leave marks behind
|
|
vim.api.nvim_win_set_cursor(0, { 1, 11 }) -- on 'gamma'
|
|
vim.cmd('VimwikiColorize blue')
|
|
local got = vim.api.nvim_get_current_line()
|
|
if got ~= 'alpha beta <span style="color:blue">gamma</span>' then
|
|
error('command wrapped stale selection instead of cword: ' .. got)
|
|
end
|
|
end)
|
|
-- The visual mapping path (explicit visual=true) wraps the selection.
|
|
tobj_case('colorize.visual_wraps_selection', function()
|
|
set_buf({ 'one two three' })
|
|
vim.cmd('normal! 0wviw\27') -- visually select 'two', marks set on exit
|
|
require('nuwiki.commands').colorize('red', true)
|
|
local got = vim.api.nvim_get_current_line()
|
|
if got ~= 'one <span style="color:red">two</span> three' then
|
|
error('visual colorize did not wrap selection: ' .. got)
|
|
end
|
|
end)
|
|
|
|
-- ===== 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)
|