diff --git a/README.md b/README.md index 92e4c26..d5da035 100644 --- a/README.md +++ b/README.md @@ -63,15 +63,20 @@ If no runner matches the current test file, test-samurai will show: Additional keymaps: -- `qn` -> close the testing floats and jump to the first quickfix entry -- `nf` -> jump to the next `[ FAIL ]` entry in the Test-Listing-Float (wraps to the first) -- `pf` -> jump to the previous `[ FAIL ]` entry in the Test-Listing-Float (wraps to the last) -- `sf` -> filter the listing to `[ FAIL ] - ...` entries -- `ss` -> filter the listing to `[ SKIP ] - ...` entries -- `sa` -> clear the listing filter and show all entries -- `tt` -> run the test under the cursor in the listing -- `cb` -> breaks test-command onto multiple lines (clears search highlight) -- `cj` -> joins test-command onto single line +- Listing navigation: + - `fn` -> [F]ind [N]ext failed test in listing (wraps to the first) + - `fp` -> [F]ind [P]revious failed test in listing (wraps to the last) + - `ff` -> [F]ind [F]irst list entry + - `o` -> jump to the test location + - `qn` -> close the testing floats and jump to the first quickfix entry +- Listing filters: + - `sf` -> filter the listing to `[ FAIL ] - ...` entries + - `ss` -> filter the listing to `[ SKIP ] - ...` entries + - `sa` -> clear the listing filter and show all entries +- Listing actions: + - `tt` -> run the test under the cursor in the listing + - `cb` -> breaks test-command onto multiple lines (clears search highlight) + - `cj` -> joins test-command onto single line - `?` -> show help with TSam commands and standard keymaps in the Detail-Float Before running any test command, test-samurai runs `:wall` to save all buffers. diff --git a/doc/test-samurai.txt b/doc/test-samurai.txt index 59e24f6..50cfd6d 100644 --- a/doc/test-samurai.txt +++ b/doc/test-samurai.txt @@ -51,16 +51,20 @@ QUICK-HELP & FLOATS *test-samurai-quickhelp* In the Testing-Float, press ? to open the quick-help in the Detail-Float. Additional keymaps: +Listing navigation: + fn [F]ind [N]ext failed test in listing + fp [F]ind [P]revious failed test in listing + o Jump to test location qn Close floats + jump to the first quickfix entry - nf Next [ FAIL ] in listing - pf Previous [ FAIL ] in listing +Listing filters: sf Filter listing to [ FAIL ] only ss Filter listing to [ SKIP ] only sa Show all listing entries (clear filter) +Listing actions: tt Run the test under the cursor in the listing cb breaks test-command onto multiple lines (clears search highlight) cj joins test-command onto single line - o Jump to test location +Testing-Float: z Toggle Detail-Float full width Focus Detail-Float (press l again for full) Focus Test-Listing-Float diff --git a/lua/test-samurai/core.lua b/lua/test-samurai/core.lua index 25f9aeb..59e0e25 100644 --- a/lua/test-samurai/core.lua +++ b/lua/test-samurai/core.lua @@ -36,6 +36,7 @@ local state = { local summary_ns = vim.api.nvim_create_namespace("TestSamuraiSummary") local result_ns = vim.api.nvim_create_namespace("TestSamuraiResult") local detail_ns = vim.api.nvim_create_namespace("TestSamuraiDetailAnsi") +local help_ns = vim.api.nvim_create_namespace("TestSamuraiHelp") local apply_border_kind local close_container local restore_listing_full @@ -55,7 +56,7 @@ local function help_lines() return { "Test-Samurai Help", "", - "TSam Commands:", + "TSam commands:", " TSamNearest tn", " TSamFile tf", " TSamAll ta", @@ -63,14 +64,22 @@ local function help_lines() " TSamFailedOnly te", " TSamShowOutput to", "", - "Standard Keymaps:", + "Listing navigation:", + " fn [F]ind [N]ext failed test in listing", + " fp [F]ind [P]revious failed test in listing", + " ff [F]ind [F]irst list entry", + " o Jump to test location", " qn Close floats + jump to first quickfix entry", - " nf Next [ FAIL ] in listing", - " pf Previous [ FAIL ] in listing", + "", + "Listing filters:", " sf Filter listing to [ FAIL ] only", " ss Filter listing to [ SKIP ] only", " sa Show all listing entries (clear filter)", + "", + "Listing actions:", " tt Run the test under the cursor", + " cb breaks test-command onto multiple lines (clears search highlight)", + " cj joins test-command onto single line", "", "Testing-Float (Listing):", " Open Detail-Float for selected test", @@ -78,9 +87,6 @@ local function help_lines() " Focus Detail-Float (press l again for full)", " Focus Test-Listing-Float", " z Toggle Detail-Float full width", - " o Jump to test location", - " cb breaks test-command onto multiple lines (clears search highlight)", - " cj joins test-command onto single line", " ? Show this help", "", "Testing-Float (Detail):", @@ -330,6 +336,25 @@ local function jump_listing_fail(direction) end end +local function jump_to_first_listing_entry() + local win = vim.api.nvim_get_current_win() + local buf = vim.api.nvim_get_current_buf() + if not (buf and vim.api.nvim_buf_is_valid(buf)) then + return + end + local total = vim.api.nvim_buf_line_count(buf) + if total == 0 then + return + end + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + for i = 1, total do + if lines[i] and lines[i]:match("^%[ %u+ %] %- ") then + vim.api.nvim_win_set_cursor(win, { i, 0 }) + return + end + end +end + local function find_normal_window() for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do local cfg = vim.api.nvim_win_get_config(win) @@ -833,12 +858,15 @@ local function create_output_win(initial_lines) vim.keymap.set("n", "z", function() M.toggle_detail_full() end, { buffer = buf, nowait = true, silent = true }) - vim.keymap.set("n", "nf", function() + vim.keymap.set("n", "fn", function() jump_listing_fail("next") end, { buffer = buf, nowait = true, silent = true }) - vim.keymap.set("n", "pf", function() + vim.keymap.set("n", "fp", function() jump_listing_fail("prev") end, { buffer = buf, nowait = true, silent = true }) + vim.keymap.set("n", "ff", function() + jump_to_first_listing_entry() + end, { buffer = buf, nowait = true, silent = true, desc = "[F]ind [F]irst list entry" }) vim.keymap.set("n", "cb", function() M.listing_break_on_dashes() end, { buffer = buf, nowait = true, silent = true }) @@ -919,12 +947,15 @@ local function reopen_output_win() vim.keymap.set("n", "z", function() M.toggle_detail_full() end, { buffer = state.last_buf, nowait = true, silent = true }) - vim.keymap.set("n", "nf", function() + vim.keymap.set("n", "fn", function() jump_listing_fail("next") end, { buffer = state.last_buf, nowait = true, silent = true }) - vim.keymap.set("n", "pf", function() + vim.keymap.set("n", "fp", function() jump_listing_fail("prev") end, { buffer = state.last_buf, nowait = true, silent = true }) + vim.keymap.set("n", "ff", function() + jump_to_first_listing_entry() + end, { buffer = state.last_buf, nowait = true, silent = true, desc = "[F]ind [F]irst list entry" }) vim.keymap.set("n", "cb", function() M.listing_break_on_dashes() end, { buffer = state.last_buf, nowait = true, silent = true }) @@ -1214,6 +1245,18 @@ local function apply_detail_highlights(buf, highlights) end end +local function apply_help_highlights(buf, lines) + if not (buf and vim.api.nvim_buf_is_valid(buf)) then + return + end + vim.api.nvim_buf_clear_namespace(buf, help_ns, 0, -1) + for lnum, line in ipairs(lines or {}) do + if line:match(":%s*$") then + vim.api.nvim_buf_add_highlight(buf, help_ns, "TestSamuraiSummaryPass", lnum - 1, 0, -1) + end + end +end + local function parse_go_output_from_raw(output) local out = {} if not output or output == "" then @@ -1274,6 +1317,7 @@ local function ensure_detail_buf(lines) end local clean_lines, highlights = parse_ansi_lines(normalize_output_lines(lines)) vim.api.nvim_buf_set_lines(buf, 0, -1, false, clean_lines) + vim.api.nvim_buf_clear_namespace(buf, help_ns, 0, -1) apply_detail_highlights(buf, highlights) return buf end @@ -1403,7 +1447,9 @@ function M.show_help() vim.notify("[test-samurai] No test output window", vim.log.levels.WARN) return end - open_detail_split(help_lines(), "default") + local lines = help_lines() + open_detail_split(lines, "default") + apply_help_highlights(state.detail_buf, lines) end function M.filter_listing_failures() diff --git a/tests/test_samurai_core_spec.lua b/tests/test_samurai_core_spec.lua index 7c4e468..8f5fe30 100644 --- a/tests/test_samurai_core_spec.lua +++ b/tests/test_samurai_core_spec.lua @@ -244,8 +244,16 @@ describe("test-samurai core (no bundled runners)", function() local joined = table.concat(lines, "\n") assert.is_true(joined:find("TSamNearest", 1, true) ~= nil) assert.is_true(joined:find("TSamShowOutput", 1, true) ~= nil) + assert.is_true(joined:find("TSam commands:", 1, true) ~= nil) + assert.is_true(joined:find("Listing navigation:", 1, true) ~= nil) + assert.is_true(joined:find("Listing filters:", 1, true) ~= nil) + assert.is_true(joined:find("Listing actions:", 1, true) ~= nil) assert.is_true(joined:find("tn", 1, true) ~= nil) assert.is_true(joined:find("to", 1, true) ~= nil) + assert.is_true(joined:find("fn", 1, true) ~= nil) + assert.is_true(joined:find("fp", 1, true) ~= nil) + assert.is_true(joined:find("[F]ind [N]ext failed test in listing", 1, true) ~= nil) + assert.is_true(joined:find("[F]ind [P]revious failed test in listing", 1, true) ~= nil) assert.is_true(joined:find("cb", 1, true) ~= nil) assert.is_true(joined:find("cj", 1, true) ~= nil) assert.is_true(joined:find("breaks test-command onto multiple lines", 1, true) ~= nil) @@ -254,6 +262,92 @@ describe("test-samurai core (no bundled runners)", function() vim.fn.jobstart = orig_jobstart end) + it("keeps failed-navigation keymaps buffer-local to the output listing", function() + local runner = { + name = "test-runner-keymaps", + } + + function runner.is_test_file(_bufnr) + return true + end + + function runner.find_nearest(bufnr, _row, _col) + return { file = vim.api.nvim_buf_get_name(bufnr), cwd = vim.loop.cwd(), test_name = "TestA" } + end + + function runner.build_command(spec) + return { cmd = { "echo", "nearest" }, cwd = spec.cwd } + end + + function runner.parse_results(_output) + return { passes = {}, failures = {}, skips = {} } + end + + function runner.output_parser() + return { + on_line = function(_line, _state) + return nil + end, + on_complete = function(output, _state) + return runner.parse_results(output) + end, + } + end + + function runner.parse_test_output(_output) + return {} + end + + package.loaded["test-samurai-keymap-runner"] = runner + test_samurai.setup({ runner_modules = { "test-samurai-keymap-runner" } }) + + local normal_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(normal_buf, "/tmp/test_samurai_keymaps.go") + vim.bo[normal_buf].filetype = "go" + vim.api.nvim_set_current_buf(normal_buf) + + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts.on_exit then + opts.on_exit(nil, 0, nil) + end + return 1 + end + + core.run_nearest() + + local listing_buf = vim.api.nvim_get_current_buf() + local listing_maps = vim.api.nvim_buf_get_keymap(listing_buf, "n") + local has_fn = false + local has_fp = false + for _, map in ipairs(listing_maps) do + if map.lhs and map.lhs:sub(-2) == "fn" then + has_fn = true + elseif map.lhs and map.lhs:sub(-2) == "fp" then + has_fp = true + end + end + assert.is_true(has_fn) + assert.is_true(has_fp) + + core.close_output_and_restore() + + local normal_maps = vim.api.nvim_buf_get_keymap(normal_buf, "n") + local normal_fn = false + local normal_fp = false + for _, map in ipairs(normal_maps) do + if map.lhs and map.lhs:sub(-2) == "fn" then + normal_fn = true + elseif map.lhs and map.lhs:sub(-2) == "fp" then + normal_fp = true + end + end + assert.is_false(normal_fn) + assert.is_false(normal_fp) + + vim.fn.jobstart = orig_jobstart + end) + it("restores cursor location after closing output with ", function() local runner = { name = "test-runner-restore", @@ -915,4 +1009,125 @@ describe("test-samurai core (no bundled runners)", function() vim.fn.jobstart = orig_jobstart end) + + it("maps ff to jump to the first listing entry", function() + local runner = { + name = "test-runner", + } + + function runner.is_test_file(_bufnr) + return true + end + + function runner.find_nearest(bufnr, _row, _col) + return { file = vim.api.nvim_buf_get_name(bufnr), cwd = vim.loop.cwd(), test_name = "TestA" } + end + + function runner.build_command(spec) + return { cmd = { "echo", "nearest" }, cwd = spec.cwd } + end + + function runner.build_file_command(_bufnr) + return { cmd = { "echo", "file" } } + end + + function runner.build_all_command(_bufnr) + return { cmd = { "echo", "all" } } + end + + function runner.build_failed_command(last_command, _failures, _scope_kind) + return { cmd = { "echo", "failed" }, cwd = last_command and last_command.cwd or nil } + end + + function runner.parse_results(output) + local passes = {} + if type(output) == "string" and output:find("PASS TestA", 1, true) then + passes = { "TestA" } + end + return { passes = passes, failures = {}, skips = {} } + end + + function runner.output_parser() + return { + on_line = function(line, _state) + if line == "PASS TestA" then + return { passes = { "TestA" }, failures = {}, skips = {} } + end + return nil + end, + on_complete = function(output, _state) + return runner.parse_results(output) + end, + } + end + + function runner.parse_test_output(_output) + return {} + end + + function runner.collect_failed_locations(_failures, _command, _scope_kind) + return {} + end + + package.loaded["test-samurai-test-runner"] = runner + test_samurai.setup({ runner_modules = { "test-samurai-test-runner" } }) + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/test_runner_listing_ff.go") + vim.bo[bufnr].filetype = "go" + vim.api.nvim_set_current_buf(bufnr) + + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts.on_stdout then + opts.on_stdout(nil, { "PASS TestA" }, nil) + end + if opts.on_exit then + opts.on_exit(nil, 0, nil) + end + return 1 + end + + core.run_nearest() + + vim.fn.jobstart = orig_jobstart + + local listing_buf = nil + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_valid(buf) and vim.bo[buf].filetype == "test-samurai-output" then + listing_buf = buf + break + end + end + assert.is_true(listing_buf ~= nil) + + local maps = vim.api.nvim_buf_get_keymap(listing_buf, "n") + local found = nil + for _, map in ipairs(maps) do + if type(map.lhs) == "string" and map.lhs:sub(-2) == "ff" then + found = map + break + end + end + assert.is_true(found ~= nil) + assert.equals("[F]ind [F]irst list entry", found.desc) + + vim.api.nvim_set_current_buf(listing_buf) + local total = vim.api.nvim_buf_line_count(listing_buf) + vim.api.nvim_win_set_cursor(0, { total, 0 }) + assert.is_true(type(found.callback) == "function") + found.callback() + + local lines = vim.api.nvim_buf_get_lines(listing_buf, 0, -1, false) + local first_entry = nil + for i, line in ipairs(lines) do + if line:match("^%[ %u+ %] %- ") then + first_entry = i + break + end + end + assert.is_true(first_entry ~= nil) + local cursor = vim.api.nvim_win_get_cursor(0) + assert.equals(first_entry, cursor[1]) + end) end)