From 5d0b4e9dd699c5bdb4746d995b0fc8027e6023da Mon Sep 17 00:00:00 2001 From: "M.Schirmer" Date: Wed, 7 Jan 2026 17:58:53 +0100 Subject: [PATCH] add keymaps for quick filtering the test-listings --- README.md | 3 + doc/test-samurai.txt | 3 + lua/test-samurai/core.lua | 147 +++++++++++++++++++++++++- tests/test_samurai_core_spec.lua | 176 +++++++++++++++++++++++++++++++ 4 files changed, 325 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c734bc8..7cd7bd3 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,9 @@ 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 - `?` -> show help with TSam commands and standard keymaps in the Detail-Float ## Output UI diff --git a/doc/test-samurai.txt b/doc/test-samurai.txt index 74e8406..2536867 100644 --- a/doc/test-samurai.txt +++ b/doc/test-samurai.txt @@ -54,6 +54,9 @@ Additional keymaps: qn Close floats + jump to the first quickfix entry nf Next [ FAIL ] in listing pf Previous [ FAIL ] in listing + sf Filter listing to [ FAIL ] only + ss Filter listing to [ SKIP ] only + sa Show all listing entries (clear filter) o Jump to test location z Toggle Detail-Float full width Focus Detail-Float (press l again for full) diff --git a/lua/test-samurai/core.lua b/lua/test-samurai/core.lua index fd3eb1c..6465dce 100644 --- a/lua/test-samurai/core.lua +++ b/lua/test-samurai/core.lua @@ -23,6 +23,8 @@ local state = { detail_win = nil, detail_opening = false, detail_full = false, + listing_unfiltered_lines = nil, + listing_filtered_kind = nil, hardtime_refcount = 0, hardtime_was_enabled = false, autocmds_set = false, @@ -36,6 +38,8 @@ local close_container local restore_listing_full local close_detail_float local jump_to_first_quickfix +local apply_summary_highlights +local apply_result_highlights local function disable_container_maps(buf) local opts = { buffer = buf, nowait = true, silent = true } @@ -59,6 +63,9 @@ local function help_lines() " qn Close floats + jump to first quickfix entry", " nf Next [ FAIL ] in listing", " pf Previous [ FAIL ] in listing", + " sf Filter listing to [ FAIL ] only", + " ss Filter listing to [ SKIP ] only", + " sa Show all listing entries (clear filter)", "", "Testing-Float (Listing):", " Open Detail-Float for selected test", @@ -80,6 +87,106 @@ local function help_lines() } end +local function split_listing_sections(lines) + local summary_start = nil + for i, line in ipairs(lines or {}) do + if line:match("^TOTAL%s+%d+") then + summary_start = i - 1 + if summary_start < 2 then + summary_start = 2 + end + break + end + end + local header = {} + local body = {} + local summary = {} + if lines and #lines > 0 then + header = { lines[1] } + end + for i, line in ipairs(lines or {}) do + if i == 1 then + elseif summary_start and i >= summary_start then + table.insert(summary, line) + else + table.insert(body, line) + end + end + return header, body, summary +end + +local function rebuild_result_line_map(lines) + state.last_result_line_map = {} + for idx, line in ipairs(lines or {}) do + local status = line:match("^%[%s*(%u+)%s*%]%s*%-") + if status == "PASS" or status == "FAIL" or status == "SKIP" then + local name = line:match("^%[%s*[%u]+%s*%]%s*%-%s*(.+)$") + if name and name ~= "" then + state.last_result_line_map[idx] = name + end + end + end +end + +local function apply_listing_lines(buf, lines) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.api.nvim_buf_clear_namespace(buf, result_ns, 0, -1) + vim.api.nvim_buf_clear_namespace(buf, summary_ns, 0, -1) + apply_result_highlights(buf, 0, lines) + apply_summary_highlights(buf, 0, lines) + rebuild_result_line_map(lines) +end + +local function apply_listing_filter(kind) + if not (state.last_buf and vim.api.nvim_buf_is_valid(state.last_buf)) then + return + end + if kind == "all" then + if not state.listing_filtered_kind or not state.listing_unfiltered_lines then + return + end + apply_listing_lines(state.last_buf, state.listing_unfiltered_lines) + state.listing_filtered_kind = nil + return + end + + if state.listing_filtered_kind == kind then + return + end + + local base = state.listing_unfiltered_lines + if not base then + base = vim.api.nvim_buf_get_lines(state.last_buf, 0, -1, false) + state.listing_unfiltered_lines = vim.deepcopy(base) + end + local header, body, summary = split_listing_sections(base) + local filtered = {} + for _, line in ipairs(body) do + if kind == "fail" and line:match("^%[ FAIL %] %-") then + table.insert(filtered, line) + elseif kind == "skip" and line:match("^%[ SKIP %] %-") then + table.insert(filtered, line) + end + end + if #filtered == 0 then + return + end + + local combined = {} + if #header > 0 then + table.insert(combined, header[1]) + table.insert(combined, "") + end + for _, line in ipairs(filtered) do + table.insert(combined, line) + end + for _, line in ipairs(summary) do + table.insert(combined, line) + end + apply_listing_lines(state.last_buf, combined) + state.listing_filtered_kind = kind +end + local function get_hardtime() local ok, hardtime = pcall(require, "hardtime") if not ok or type(hardtime) ~= "table" then @@ -663,6 +770,15 @@ local function create_output_win(initial_lines) vim.keymap.set("n", "qn", function() jump_to_first_quickfix() end, { buffer = buf, nowait = true, silent = true }) + vim.keymap.set("n", "sf", function() + M.filter_listing_failures() + end, { buffer = buf, nowait = true, silent = true }) + vim.keymap.set("n", "ss", function() + M.filter_listing_skips() + end, { buffer = buf, nowait = true, silent = true }) + vim.keymap.set("n", "sa", function() + M.filter_listing_all() + end, { buffer = buf, nowait = true, silent = true }) vim.keymap.set("n", "?", function() M.show_help() end, { buffer = buf, nowait = true, silent = true }) @@ -730,6 +846,15 @@ local function reopen_output_win() vim.keymap.set("n", "qn", function() jump_to_first_quickfix() end, { buffer = state.last_buf, nowait = true, silent = true }) + vim.keymap.set("n", "sf", function() + M.filter_listing_failures() + end, { buffer = state.last_buf, nowait = true, silent = true }) + vim.keymap.set("n", "ss", function() + M.filter_listing_skips() + end, { buffer = state.last_buf, nowait = true, silent = true }) + vim.keymap.set("n", "sa", function() + M.filter_listing_all() + end, { buffer = state.last_buf, nowait = true, silent = true }) vim.keymap.set("n", "?", function() M.show_help() end, { buffer = state.last_buf, nowait = true, silent = true }) @@ -1187,6 +1312,18 @@ function M.show_help() open_detail_split(help_lines(), "default") end +function M.filter_listing_failures() + apply_listing_filter("fail") +end + +function M.filter_listing_skips() + apply_listing_filter("skip") +end + +function M.filter_listing_all() + apply_listing_filter("all") +end + function M.focus_listing() if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then if state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) then @@ -1579,7 +1716,7 @@ local function highlight_label_word(buf, ns, lnum, line, label, hl_group) vim.api.nvim_buf_add_highlight(buf, ns, hl_group, lnum, label_start - 1, label_start - 1 + #label) end -local function apply_summary_highlights(buf, start_line, lines) +apply_summary_highlights = function(buf, start_line, lines) if not (buf and vim.api.nvim_buf_is_valid(buf)) then return end @@ -1601,7 +1738,7 @@ local function apply_summary_highlights(buf, start_line, lines) end end -local function apply_result_highlights(buf, start_line, lines) +apply_result_highlights = function(buf, start_line, lines) if not (buf and vim.api.nvim_buf_is_valid(buf)) then return end @@ -1644,6 +1781,8 @@ local function run_command(command, opts) state.last_test_outputs = {} state.last_result_line_map = {} state.last_raw_output = nil + state.listing_unfiltered_lines = nil + state.listing_filtered_kind = nil local failures = {} local failures_seen = {} if command and type(command.cmd) == "table" and #command.cmd > 0 then @@ -1858,7 +1997,7 @@ local function run_command(command, opts) append_lines(buf, summary_lines) apply_summary_highlights(buf, start_line, summary_lines) end - append_lines(buf, { "", "[exit code] " .. tostring(code) }) + append_lines(buf, { "" }) else if not has_output then local cur = vim.api.nvim_buf_get_lines(buf, 0, -1, false) @@ -1877,7 +2016,7 @@ local function run_command(command, opts) append_lines(buf, summary_lines) apply_summary_highlights(buf, start_line, summary_lines) end - append_lines(buf, { "", "[exit code] " .. tostring(code) }) + append_lines(buf, { "" }) end if options.track_scope then state.last_scope_exit_code = code diff --git a/tests/test_samurai_core_spec.lua b/tests/test_samurai_core_spec.lua index 44b7d9a..ff91527 100644 --- a/tests/test_samurai_core_spec.lua +++ b/tests/test_samurai_core_spec.lua @@ -242,4 +242,180 @@ describe("test-samurai core (no bundled runners)", function() vim.fn.jobstart = orig_jobstart end) + + it("filters listing entries and restores them", function() + local runner = { + name = "test-runner-filter", + } + + 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) + return { passes = { "TestA" }, failures = { "TestC" }, skips = { "TestB" } } + 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 + + function runner.collect_failed_locations(_failures, _command, _scope_kind) + return {} + end + + package.loaded["test-samurai-filter-runner"] = runner + test_samurai.setup({ runner_modules = { "test-samurai-filter-runner" } }) + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/test_samurai_filter.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_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 original = vim.api.nvim_buf_get_lines(listing_buf, 0, -1, false) + + core.filter_listing_failures() + local failures_only = vim.api.nvim_buf_get_lines(listing_buf, 0, -1, false) + local failures_joined = table.concat(failures_only, "\n") + assert.is_true(failures_joined:find("[ FAIL ] - TestC", 1, true) ~= nil) + assert.is_true(failures_joined:find("[ SKIP ] - TestB", 1, true) == nil) + assert.equals(original[1], failures_only[1]) + assert.equals("", failures_only[2]) + assert.is_true(failures_joined:find("TOTAL", 1, true) ~= nil) + + core.filter_listing_skips() + local skips_only = vim.api.nvim_buf_get_lines(listing_buf, 0, -1, false) + local skips_joined = table.concat(skips_only, "\n") + assert.is_true(skips_joined:find("[ SKIP ] - TestB", 1, true) ~= nil) + assert.is_true(skips_joined:find("[ FAIL ] - TestC", 1, true) == nil) + + core.filter_listing_all() + local restored = vim.api.nvim_buf_get_lines(listing_buf, 0, -1, false) + assert.equals(table.concat(original, "\n"), table.concat(restored, "\n")) + + vim.fn.jobstart = orig_jobstart + end) + + it("keeps the listing unchanged when no filter matches exist", function() + local runner = { + name = "test-runner-no-matches", + } + + 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) + return { passes = { "TestA" }, 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 + + function runner.collect_failed_locations(_failures, _command, _scope_kind) + return {} + end + + package.loaded["test-samurai-no-match-runner"] = runner + test_samurai.setup({ runner_modules = { "test-samurai-no-match-runner" } }) + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/test_samurai_no_match.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_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 original = vim.api.nvim_buf_get_lines(listing_buf, 0, -1, false) + + core.filter_listing_failures() + core.filter_listing_skips() + local after = vim.api.nvim_buf_get_lines(listing_buf, 0, -1, false) + assert.equals(table.concat(original, "\n"), table.concat(after, "\n")) + + vim.fn.jobstart = orig_jobstart + end) end)