diff --git a/AGENTS.md b/AGENTS.md index 38dbee0..3fb6f5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,4 @@ - Neue Features benötigen Tests ## Einschränkungen -- Failed-only: nur Go - Lua Nearest pausiert -- Farbiger Output später diff --git a/lua/test-samurai/core.lua b/lua/test-samurai/core.lua index efac369..487a7a1 100644 --- a/lua/test-samurai/core.lua +++ b/lua/test-samurai/core.lua @@ -22,6 +22,9 @@ local state = { detail_buf = nil, detail_win = nil, detail_opening = false, + detail_full = false, + hardtime_refcount = 0, + hardtime_was_enabled = false, autocmds_set = false, } @@ -31,6 +34,69 @@ local detail_ns = vim.api.nvim_create_namespace("TestSamuraiDetailAnsi") local apply_border_kind local close_container local restore_listing_full +local close_detail_float + +local function disable_container_maps(buf) + local opts = { buffer = buf, nowait = true, silent = true } + vim.keymap.set("n", "", "", opts) + vim.keymap.set("n", "", "", opts) +end + +local function get_hardtime() + local ok, hardtime = pcall(require, "hardtime") + if not ok or type(hardtime) ~= "table" then + return nil + end + if type(hardtime.enable) ~= "function" or type(hardtime.disable) ~= "function" then + return nil + end + return hardtime +end + +local function hardtime_disable() + local hardtime = get_hardtime() + if not hardtime then + return + end + if state.hardtime_refcount == 0 then + state.hardtime_was_enabled = hardtime.is_plugin_enabled == true + end + state.hardtime_refcount = state.hardtime_refcount + 1 + if hardtime.is_plugin_enabled == true then + pcall(hardtime.disable) + end +end + +local function hardtime_restore() + if state.hardtime_refcount == 0 then + return + end + state.hardtime_refcount = state.hardtime_refcount - 1 + if state.hardtime_refcount > 0 then + return + end + if not state.hardtime_was_enabled then + return + end + local hardtime = get_hardtime() + if hardtime then + pcall(hardtime.enable) + end + state.hardtime_was_enabled = false +end + +local function handle_ctrl_l_in_listing() + local next_key = vim.fn.getchar(0) + if next_key ~= 0 and next_key ~= -1 then + local char = vim.fn.nr2char(next_key) + if char == "l" then + M.expand_detail_full() + return + end + vim.api.nvim_feedkeys(char, "n", false) + end + M.focus_detail() +end local function get_hl_fg(name) local ok, hl = pcall(vim.api.nvim_get_hl, 0, { name = name, link = true }) @@ -87,21 +153,6 @@ local function ensure_output_autocmds() local group = vim.api.nvim_create_augroup("TestSamuraiOutput", { clear = true }) - vim.api.nvim_create_autocmd({ "BufEnter", "WinEnter" }, { - group = group, - callback = function() - if state.detail_opening then - return - end - local cur = vim.api.nvim_get_current_win() - local keep = (state.last_win and vim.api.nvim_win_is_valid(state.last_win) and cur == state.last_win) - or (state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) and cur == state.detail_win) - if not keep then - close_container() - end - end, - }) - vim.api.nvim_create_autocmd("WinClosed", { group = group, callback = function(args) @@ -109,14 +160,20 @@ local function ensure_output_autocmds() if not closed then return end - if state.last_win and closed == state.last_win then - state.last_win = nil - close_container() - return - end if state.detail_win and closed == state.detail_win then state.detail_win = nil restore_listing_full() + hardtime_restore() + return + end + if state.last_win and closed == state.last_win then + state.last_win = nil + hardtime_restore() + if state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) then + pcall(vim.api.nvim_win_close, state.detail_win, true) + state.detail_win = nil + end + return end end, }) @@ -147,8 +204,12 @@ function M.setup() state.last_result_line_map = {} state.last_raw_output = nil state.last_float = nil + state.last_win = nil + state.last_buf = nil state.detail_opening = false - state.detail_opening = false + state.detail_full = false + state.hardtime_refcount = 0 + state.hardtime_was_enabled = false end function M.reload_runners() @@ -261,27 +322,43 @@ local function float_geometry() return width, height, row, col end +local function base_geometry() + local width, height, row, col = float_geometry() + local base = state.last_float or {} + return base.width or width, base.height or height, base.row or row, base.col or col +end + apply_border_kind = function(win, kind) - if not (win and vim.api.nvim_win_is_valid(win)) then + local target = win + if not target then + target = state.last_win + end + if not (target and vim.api.nvim_win_is_valid(target)) then return end if kind == "pass" then - vim.api.nvim_win_set_option(win, "winhighlight", "FloatBorder:TestSamuraiBorderPass") + vim.api.nvim_win_set_option(target, "winhighlight", "FloatBorder:TestSamuraiBorderPass") elseif kind == "fail" then - vim.api.nvim_win_set_option(win, "winhighlight", "FloatBorder:TestSamuraiBorderFail") + vim.api.nvim_win_set_option(target, "winhighlight", "FloatBorder:TestSamuraiBorderFail") else - vim.api.nvim_win_set_option(win, "winhighlight", "") + vim.api.nvim_win_set_option(target, "winhighlight", "") end end close_container = function() + if state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) then + pcall(vim.api.nvim_win_close, state.detail_win, true) + state.detail_win = nil + end if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then pcall(vim.api.nvim_win_close, state.last_win, true) state.last_win = nil end +end + +close_detail_float = function() if state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) then pcall(vim.api.nvim_win_close, state.detail_win, true) - state.detail_win = nil end end @@ -289,10 +366,7 @@ restore_listing_full = function() if not (state.last_win and vim.api.nvim_win_is_valid(state.last_win)) then return end - local width = state.last_float and state.last_float.width or math.floor(vim.o.columns * 0.8) - local height = state.last_float and state.last_float.height or math.floor(vim.o.lines * 0.8) - local row = state.last_float and state.last_float.row or math.floor((vim.o.lines - height) / 2) - local col = state.last_float and state.last_float.col or math.floor((vim.o.columns - width) / 2) + local width, height, row, col = base_geometry() vim.api.nvim_win_set_config(state.last_win, { relative = "editor", width = width, @@ -302,18 +376,71 @@ restore_listing_full = function() style = "minimal", border = "rounded", }) - apply_border_kind(state.last_win, state.last_border_kind) +end + +local function apply_split_layout(left_ratio) + if not (state.last_win and vim.api.nvim_win_is_valid(state.last_win)) then + return + end + if not (state.detail_win and vim.api.nvim_win_is_valid(state.detail_win)) then + return + end + local width, height, row, col = base_geometry() + local available = math.max(1, width - 2) + local left_width = math.floor(available * left_ratio) + if left_width < 1 then + left_width = 1 + end + if left_width >= available then + left_width = math.max(1, available - 1) + end + local right_width = available - left_width + if right_width < 1 then + right_width = 1 + if available > 1 then + left_width = available - right_width + end + end + local listing_border = "rounded" + local detail_col = col + left_width + 2 + if left_ratio <= 0 then + left_width = 1 + right_width = width + listing_border = "none" + detail_col = col + state.detail_full = true + else + state.detail_full = false + end + vim.api.nvim_win_set_config(state.last_win, { + relative = "editor", + width = left_width, + height = height, + row = row, + col = col, + style = "minimal", + border = listing_border, + }) + vim.api.nvim_win_set_config(state.detail_win, { + relative = "editor", + width = right_width, + height = height, + row = row, + col = detail_col, + style = "minimal", + border = "rounded", + }) end local function create_output_win(initial_lines) - if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then - pcall(vim.api.nvim_win_close, state.last_win, true) - state.last_win = nil - end if state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) then pcall(vim.api.nvim_win_close, state.detail_win, true) state.detail_win = nil end + if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then + pcall(vim.api.nvim_win_close, state.last_win, true) + state.last_win = nil + end local buf = state.last_buf if not (buf and vim.api.nvim_buf_is_valid(buf)) then @@ -328,7 +455,7 @@ local function create_output_win(initial_lines) local width, height, row, col = float_geometry() state.last_float = { width = width, height = height, row = row, col = col } - local win = vim.api.nvim_open_win(buf, true, { + local listing = vim.api.nvim_open_win(buf, true, { relative = "editor", width = width, height = height, @@ -337,6 +464,7 @@ local function create_output_win(initial_lines) style = "minimal", border = "rounded", }) + hardtime_disable() vim.keymap.set("n", "", function() close_container() @@ -344,12 +472,22 @@ local function create_output_win(initial_lines) vim.keymap.set("n", "", function() M.open_test_output_at_cursor() end, { buffer = buf, nowait = true, silent = true }) + vim.keymap.set("n", "", function() + M.focus_listing() + end, { buffer = buf, nowait = true, silent = true }) + vim.keymap.set("n", "", function() + handle_ctrl_l_in_listing() + end, { buffer = buf, silent = true }) + vim.keymap.set("n", "z", function() + M.toggle_detail_full() + end, { buffer = buf, nowait = true, silent = true }) + disable_container_maps(buf) - state.last_win = win + state.last_win = listing state.last_buf = buf - apply_border_kind(win, state.last_border_kind) + apply_border_kind(listing, state.last_border_kind) - return buf, win + return buf, listing end local function reopen_output_win() @@ -378,6 +516,7 @@ local function reopen_output_win() style = "minimal", border = "rounded", }) + hardtime_disable() vim.keymap.set("n", "", function() close_container() @@ -385,6 +524,16 @@ local function reopen_output_win() vim.keymap.set("n", "", function() M.open_test_output_at_cursor() end, { buffer = state.last_buf, nowait = true, silent = true }) + vim.keymap.set("n", "", function() + M.focus_listing() + end, { buffer = state.last_buf, nowait = true, silent = true }) + vim.keymap.set("n", "", function() + handle_ctrl_l_in_listing() + end, { buffer = state.last_buf, silent = true }) + vim.keymap.set("n", "z", function() + M.toggle_detail_full() + end, { buffer = state.last_buf, nowait = true, silent = true }) + disable_container_maps(state.last_buf) state.last_win = win apply_border_kind(win, state.last_border_kind) @@ -684,6 +833,19 @@ local function ensure_detail_buf(lines) vim.keymap.set("n", "h", function() M.focus_listing() end, { buffer = buf, nowait = true, silent = true }) + vim.keymap.set("n", "", function() + M.focus_listing() + end, { buffer = buf, nowait = true, silent = true }) + vim.keymap.set("n", "", function() + M.focus_detail() + end, { buffer = buf, nowait = true, silent = true }) + vim.keymap.set("n", "z", function() + M.toggle_detail_full() + end, { buffer = buf, nowait = true, silent = true }) + vim.keymap.set("n", "", function() + close_detail_float() + end, { buffer = buf, nowait = true, silent = true }) + disable_container_maps(buf) end local clean_lines, highlights = parse_ansi_lines(normalize_output_lines(lines)) vim.api.nvim_buf_set_lines(buf, 0, -1, false, clean_lines) @@ -691,43 +853,50 @@ local function ensure_detail_buf(lines) return buf end -local function open_detail_split(lines) +local function open_detail_split(lines, border_kind) local buf = ensure_detail_buf(lines) - local base = state.last_float or {} - local width = base.width or math.floor(vim.o.columns * 0.8) - local height = base.height or math.floor(vim.o.lines * 0.8) - local row = base.row or math.floor((vim.o.lines - height) / 2) - local col = base.col or math.floor((vim.o.columns - width) / 2) - local left_width = math.max(1, math.floor(width * 0.2)) - local right_width = width - left_width + if not (state.last_win and vim.api.nvim_win_is_valid(state.last_win)) then + return + end + local width, height, row, col = base_geometry() + local available = math.max(1, width - 2) + local left_width = math.floor(available * 0.25) + if left_width < 1 then + left_width = 1 + end + if left_width >= available then + left_width = math.max(1, available - 1) + end + local right_width = available - left_width if right_width < 1 then right_width = 1 - left_width = math.max(1, width - right_width) + if available > 1 then + left_width = available - right_width + end end - if state.last_win and vim.api.nvim_win_is_valid(state.last_win) then - vim.api.nvim_win_set_config(state.last_win, { - relative = "editor", - width = left_width, - height = height, - row = row, - col = col, - style = "minimal", - border = "rounded", - }) - end + vim.api.nvim_win_set_config(state.last_win, { + relative = "editor", + width = left_width, + height = height, + row = row, + col = col, + style = "minimal", + border = "rounded", + }) local right_cfg = { relative = "editor", width = right_width, height = height, row = row, - col = col + left_width, + col = col + left_width + 2, style = "minimal", border = "rounded", } local right = state.detail_win + local opening_detail = not (right and vim.api.nvim_win_is_valid(right)) if right and vim.api.nvim_win_is_valid(right) then vim.api.nvim_win_set_buf(right, buf) vim.api.nvim_win_set_config(right, right_cfg) @@ -737,15 +906,24 @@ local function open_detail_split(lines) state.detail_win = right state.detail_opening = false end + if opening_detail then + hardtime_disable() + end + apply_border_kind(right, border_kind) + state.detail_full = false vim.api.nvim_set_current_win(right) end function M.open_test_output_at_cursor() local cursor = vim.api.nvim_win_get_cursor(0) local line = cursor[1] + local text = vim.api.nvim_get_current_line() + local status = text:match("^%[%s*(%u+)%s*%]%s*%-") + if status and status ~= "PASS" and status ~= "FAIL" then + return + end local test_name = state.last_result_line_map[line] if not test_name then - local text = vim.api.nvim_get_current_line() test_name = text:match("^%[%s*[%u]+%s*%]%s*%-%s*(.+)$") end if not test_name then @@ -791,15 +969,44 @@ function M.open_test_output_at_cursor() vim.notify("[test-samurai] No output captured for " .. test_name, vim.log.levels.WARN) return end - open_detail_split(output) + local border_kind = status and status:lower() or nil + open_detail_split(output, border_kind) 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 + apply_split_layout(0.25) + end vim.api.nvim_set_current_win(state.last_win) end end +function M.focus_detail() + if state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) then + vim.api.nvim_set_current_win(state.detail_win) + end +end + +function M.expand_detail_full() + if state.detail_win and vim.api.nvim_win_is_valid(state.detail_win) then + apply_split_layout(0) + vim.api.nvim_set_current_win(state.detail_win) + end +end + +function M.toggle_detail_full() + if not (state.detail_win and vim.api.nvim_win_is_valid(state.detail_win)) then + return + end + if state.detail_full then + apply_split_layout(0.25) + vim.api.nvim_set_current_win(state.last_win) + else + M.expand_detail_full() + end +end + local function run_cmd(cmd, cwd, handlers) local h = handlers or {} diff --git a/tests/test_samurai_output_spec.lua b/tests/test_samurai_output_spec.lua index 96a2dfb..ee9bc40 100644 --- a/tests/test_samurai_output_spec.lua +++ b/tests/test_samurai_output_spec.lua @@ -1,6 +1,62 @@ local test_samurai = require("test-samurai") local core = require("test-samurai.core") +local function close_output_container() + local keys = vim.api.nvim_replace_termcodes("", true, false, true) + local attempts = 5 + while attempts > 0 do + local float_win = nil + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + local cfg = vim.api.nvim_win_get_config(win) + if cfg.relative ~= "" then + float_win = win + break + end + end + if not float_win then + break + end + vim.api.nvim_set_current_win(float_win) + vim.api.nvim_feedkeys(keys, "x", false) + vim.wait(20, function() + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + local cfg = vim.api.nvim_win_get_config(win) + if cfg.relative ~= "" then + return false + end + end + return true + end) + attempts = attempts - 1 + end +end + +local function has_nop_mapping(buf, lhs) + local check = { lhs } + if lhs == "" then + table.insert(check, "") + end + if lhs == "" then + table.insert(check, "") + end + for _, map in ipairs(vim.api.nvim_buf_get_keymap(buf, "n")) do + local matched = false + for _, key in ipairs(check) do + if (map.lhs or ""):lower() == key:lower() then + matched = true + break + end + end + if matched then + local rhs = map.rhs or "" + if rhs == "" or rhs == "" or rhs == "" then + return true + end + end + end + return false +end + describe("test-samurai public API", function() it("delegates show_output to core", function() local called = false @@ -98,6 +154,25 @@ describe("test-samurai output formatting", function() test_samurai.setup() end) + after_each(function() + close_output_container() + end) + + local function find_listing_win() + local current = vim.api.nvim_get_current_win() + local cfg = vim.api.nvim_win_get_config(current) + if cfg.relative ~= "" then + return current + end + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + local win_cfg = vim.api.nvim_win_get_config(win) + if win_cfg.relative ~= "" then + return win + end + end + return nil + end + it("formats JSON output as PASS/FAIL lines", function() local json = vim.json.encode({ testResults = { @@ -265,8 +340,9 @@ describe("test-samurai output formatting", function() core.run_nearest() - local win = vim.api.nvim_get_current_win() - local hl = vim.api.nvim_win_get_option(win, "winhighlight") + local listing = find_listing_win() + assert.is_not_nil(listing) + local hl = vim.api.nvim_win_get_option(listing, "winhighlight") vim.fn.jobstart = orig_jobstart @@ -311,8 +387,9 @@ describe("test-samurai output formatting", function() core.run_nearest() - local win = vim.api.nvim_get_current_win() - local hl = vim.api.nvim_win_get_option(win, "winhighlight") + local listing = find_listing_win() + assert.is_not_nil(listing) + local hl = vim.api.nvim_win_get_option(listing, "winhighlight") vim.fn.jobstart = orig_jobstart @@ -1406,6 +1483,10 @@ describe("test-samurai output detail view", function() test_samurai.setup() end) + after_each(function() + close_output_container() + end) + local function find_float_wins() local wins = vim.api.nvim_tabpage_list_wins(0) local out = {} @@ -1418,10 +1499,9 @@ describe("test-samurai output detail view", function() return out end - local function find_non_float_win() - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - local cfg = vim.api.nvim_win_get_config(win) - if cfg.relative == "" then + local function find_detail_win(listing) + for _, win in ipairs(find_float_wins()) do + if win ~= listing then return win end end @@ -1467,21 +1547,17 @@ describe("test-samurai output detail view", function() core.open_test_output_at_cursor() local wins = find_float_wins() assert.equals(2, #wins) - local right = nil - for _, win in ipairs(wins) do - if win ~= output_win then - right = win - break - end - end + local right = find_detail_win(output_win) assert.is_not_nil(right) local left_cfg = vim.api.nvim_win_get_config(output_win) local right_cfg = vim.api.nvim_win_get_config(right) + assert.is_true(left_cfg.border == "rounded" or type(left_cfg.border) == "table") + assert.is_true(right_cfg.border == "rounded" or type(right_cfg.border) == "table") assert.equals(left_cfg.row, right_cfg.row) assert.equals(left_cfg.height, right_cfg.height) - assert.equals(left_cfg.col + left_cfg.width, right_cfg.col) + assert.is_true(right_cfg.col >= left_cfg.col + left_cfg.width + 2) local total_width = left_cfg.width + right_cfg.width - local expected_left = math.floor(total_width * 0.2) + local expected_left = math.floor(total_width * 0.25) assert.is_true(math.abs(left_cfg.width - expected_left) <= 1) local right_buf = vim.api.nvim_win_get_buf(right) local detail = vim.api.nvim_buf_get_lines(right_buf, 0, -1, false) @@ -1532,13 +1608,7 @@ describe("test-samurai output detail view", function() core.open_test_output_at_cursor() local wins = find_float_wins() - local right = nil - for _, win in ipairs(wins) do - if win ~= output_win then - right = win - break - end - end + local right = find_detail_win(output_win) assert.is_not_nil(right) local right_buf = vim.api.nvim_win_get_buf(right) vim.api.nvim_buf_set_lines(right_buf, 0, -1, false, { "old output" }) @@ -1547,13 +1617,7 @@ describe("test-samurai output detail view", function() core.open_test_output_at_cursor() local updated = find_float_wins() - local updated_right = nil - for _, win in ipairs(updated) do - if win ~= output_win then - updated_right = win - break - end - end + local updated_right = find_detail_win(output_win) assert.equals(right, updated_right) local detail = vim.api.nvim_buf_get_lines(right_buf, 0, -1, false) assert.are.same({ @@ -1601,13 +1665,7 @@ describe("test-samurai output detail view", function() core.open_test_output_at_cursor() local wins = find_float_wins() - local right = nil - for _, win in ipairs(wins) do - if win ~= output_win then - right = win - break - end - end + local right = find_detail_win(output_win) assert.is_not_nil(right) local right_buf = vim.api.nvim_win_get_buf(right) local detail = vim.api.nvim_buf_get_lines(right_buf, 0, -1, false) @@ -1622,6 +1680,156 @@ describe("test-samurai output detail view", function() vim.fn.jobstart = orig_jobstart end) + it("opens detail output for PASS entries", function() + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { + vim.json.encode({ Action = "output", Test = "TestFoo/Sub", Output = "PASS detail\n" }), + vim.json.encode({ Action = "pass", Test = "TestFoo/Sub" }), + }, nil) + end + if opts and opts.on_exit then + opts.on_exit(1, 0, nil) + end + return 1 + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/output_detail_pass_test.go") + vim.bo[bufnr].filetype = "go" + vim.api.nvim_set_current_buf(bufnr) + + test_samurai.test_file() + + local out_buf = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(out_buf, 0, -1, false) + local target = nil + for i, line in ipairs(lines) do + if line:match("^%[ PASS %] %- ") then + target = i + break + end + end + assert.is_not_nil(target) + vim.api.nvim_win_set_cursor(0, { target, 0 }) + + local output_win = vim.api.nvim_get_current_win() + + core.open_test_output_at_cursor() + + local wins = find_float_wins() + assert.equals(2, #wins) + local right = find_detail_win(output_win) + assert.is_not_nil(right) + local right_buf = vim.api.nvim_win_get_buf(right) + local detail = vim.api.nvim_buf_get_lines(right_buf, 0, -1, false) + assert.are.same({ "PASS detail" }, detail) + + vim.fn.jobstart = orig_jobstart + end) + + it("colors detail border based on selected result", function() + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { + vim.json.encode({ Action = "output", Test = "TestFoo/Pass", Output = "PASS detail\n" }), + vim.json.encode({ Action = "pass", Test = "TestFoo/Pass" }), + vim.json.encode({ Action = "output", Test = "TestFoo/Fail", Output = "FAIL detail\n" }), + vim.json.encode({ Action = "fail", Test = "TestFoo/Fail" }), + }, nil) + end + if opts and opts.on_exit then + opts.on_exit(1, 1, nil) + end + return 1 + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/output_detail_border_test.go") + vim.bo[bufnr].filetype = "go" + vim.api.nvim_set_current_buf(bufnr) + + test_samurai.test_file() + + local out_buf = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(out_buf, 0, -1, false) + local pass_line = nil + local fail_line = nil + for i, line in ipairs(lines) do + if line:match("^%[ PASS %] %- ") then + pass_line = i + elseif line:match("^%[ FAIL %] %- ") then + fail_line = i + end + end + assert.is_not_nil(pass_line) + assert.is_not_nil(fail_line) + + local output_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_cursor(0, { pass_line, 0 }) + core.open_test_output_at_cursor() + local detail_win = find_detail_win(output_win) + assert.is_not_nil(detail_win) + local pass_hl = vim.api.nvim_win_get_option(detail_win, "winhighlight") + assert.equals("FloatBorder:TestSamuraiBorderPass", pass_hl) + + vim.api.nvim_set_current_win(output_win) + vim.api.nvim_win_set_cursor(0, { fail_line, 0 }) + core.open_test_output_at_cursor() + local fail_hl = vim.api.nvim_win_get_option(detail_win, "winhighlight") + assert.equals("FloatBorder:TestSamuraiBorderFail", fail_hl) + + vim.fn.jobstart = orig_jobstart + end) + + it("does not open detail output for SKIP entries", function() + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { + vim.json.encode({ Action = "output", Test = "TestFoo/Sub", Output = "skip detail\n" }), + vim.json.encode({ Action = "skip", Test = "TestFoo/Sub" }), + }, nil) + end + if opts and opts.on_exit then + opts.on_exit(1, 0, nil) + end + return 1 + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/output_detail_skip_test.go") + vim.bo[bufnr].filetype = "go" + vim.api.nvim_set_current_buf(bufnr) + + test_samurai.test_file() + + local out_buf = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(out_buf, 0, -1, false) + local target = nil + for i, line in ipairs(lines) do + if line:match("^%[ SKIP %] %- ") then + target = i + break + end + end + assert.is_not_nil(target) + vim.api.nvim_win_set_cursor(0, { target, 0 }) + + local output_win = vim.api.nvim_get_current_win() + + core.open_test_output_at_cursor() + + local wins = find_float_wins() + assert.equals(1, #wins) + local right = find_detail_win(output_win) + assert.is_nil(right) + + vim.fn.jobstart = orig_jobstart + end) + it("closes detail float and restores listing width", function() local orig_jobstart = vim.fn.jobstart vim.fn.jobstart = function(_cmd, opts) @@ -1660,13 +1868,7 @@ describe("test-samurai output detail view", function() local wins = find_float_wins() assert.equals(2, #wins) - local right = nil - for _, win in ipairs(wins) do - if win ~= output_win then - right = win - break - end - end + local right = find_detail_win(output_win) assert.is_not_nil(right) local left_cfg = vim.api.nvim_win_get_config(output_win) local right_cfg = vim.api.nvim_win_get_config(right) @@ -1676,13 +1878,400 @@ describe("test-samurai output detail view", function() local remaining = find_float_wins() assert.equals(1, #remaining) - local cfg = vim.api.nvim_win_get_config(remaining[1]) - assert.equals(total_width, cfg.width) + local listing_cfg = vim.api.nvim_win_get_config(output_win) + assert.is_true(listing_cfg.border == "rounded" or type(listing_cfg.border) == "table") + assert.equals(total_width + 2, listing_cfg.width) vim.fn.jobstart = orig_jobstart end) - it("closes container when listing float is closed", function() + it("closes detail float with and restores listing width", function() + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { + vim.json.encode({ Action = "output", Test = "TestFoo/Sub", Output = "=== RUN TestFoo/Sub\n" }), + vim.json.encode({ Action = "fail", Test = "TestFoo/Sub" }), + }, nil) + end + if opts and opts.on_exit then + opts.on_exit(1, 1, nil) + end + return 1 + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/output_detail_close_key_test.go") + vim.bo[bufnr].filetype = "go" + vim.api.nvim_set_current_buf(bufnr) + + test_samurai.test_file() + local out_buf = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(out_buf, 0, -1, false) + local target = nil + for i, line in ipairs(lines) do + if line:match("^%[ FAIL %] %- ") then + target = i + break + end + end + assert.is_not_nil(target) + vim.api.nvim_win_set_cursor(0, { target, 0 }) + + local output_win = vim.api.nvim_get_current_win() + core.open_test_output_at_cursor() + + local wins = find_float_wins() + local detail_win = find_detail_win(output_win) + assert.is_not_nil(detail_win) + local left_cfg = vim.api.nvim_win_get_config(output_win) + local right_cfg = vim.api.nvim_win_get_config(detail_win) + local total_width = left_cfg.width + right_cfg.width + + vim.api.nvim_set_current_win(detail_win) + local keys = vim.api.nvim_replace_termcodes("", true, false, true) + vim.api.nvim_feedkeys(keys, "x", false) + vim.wait(20, function() + return #find_float_wins() == 1 + end) + + local remaining = find_float_wins() + assert.equals(1, #remaining) + local listing_cfg = vim.api.nvim_win_get_config(output_win) + assert.equals(total_width + 2, listing_cfg.width) + + vim.fn.jobstart = orig_jobstart + end) + + it("disables ctrl-j/ctrl-k mappings in listing and detail buffers", function() + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { + vim.json.encode({ Action = "output", Test = "TestFoo/Sub", Output = "=== RUN TestFoo/Sub\n" }), + vim.json.encode({ Action = "fail", Test = "TestFoo/Sub" }), + }, nil) + end + if opts and opts.on_exit then + opts.on_exit(1, 1, nil) + end + return 1 + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/output_detail_ctrl_map_test.go") + vim.bo[bufnr].filetype = "go" + vim.api.nvim_set_current_buf(bufnr) + + test_samurai.test_file() + local listing_buf = vim.api.nvim_get_current_buf() + assert.is_true(has_nop_mapping(listing_buf, "")) + assert.is_true(has_nop_mapping(listing_buf, "")) + + local lines = vim.api.nvim_buf_get_lines(listing_buf, 0, -1, false) + local target = nil + for i, line in ipairs(lines) do + if line:match("^%[ FAIL %] %- ") then + target = i + break + end + end + assert.is_not_nil(target) + vim.api.nvim_win_set_cursor(0, { target, 0 }) + + local output_win = vim.api.nvim_get_current_win() + core.open_test_output_at_cursor() + + local detail_win = find_detail_win(output_win) + assert.is_not_nil(detail_win) + local detail_buf = vim.api.nvim_win_get_buf(detail_win) + assert.is_true(has_nop_mapping(detail_buf, "")) + assert.is_true(has_nop_mapping(detail_buf, "")) + + vim.fn.jobstart = orig_jobstart + end) + + it("focuses listing with from detail", function() + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { + vim.json.encode({ Action = "output", Test = "TestFoo/Sub", Output = "=== RUN TestFoo/Sub\n" }), + vim.json.encode({ Action = "fail", Test = "TestFoo/Sub" }), + }, nil) + end + if opts and opts.on_exit then + opts.on_exit(1, 1, nil) + end + return 1 + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/output_detail_focus_h_test.go") + vim.bo[bufnr].filetype = "go" + vim.api.nvim_set_current_buf(bufnr) + + test_samurai.test_file() + local out_buf = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(out_buf, 0, -1, false) + local target = nil + for i, line in ipairs(lines) do + if line:match("^%[ FAIL %] %- ") then + target = i + break + end + end + assert.is_not_nil(target) + vim.api.nvim_win_set_cursor(0, { target, 0 }) + + local output_win = vim.api.nvim_get_current_win() + core.open_test_output_at_cursor() + + local detail_win = find_detail_win(output_win) + assert.is_not_nil(detail_win) + vim.api.nvim_set_current_win(detail_win) + + local keys = vim.api.nvim_replace_termcodes("", true, false, true) + vim.api.nvim_feedkeys(keys, "x", false) + vim.wait(20, function() + return vim.api.nvim_get_current_win() == output_win + end) + + assert.equals(output_win, vim.api.nvim_get_current_win()) + + vim.fn.jobstart = orig_jobstart + end) + + it("focuses detail with from listing when detail is visible", function() + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { + vim.json.encode({ Action = "output", Test = "TestFoo/Sub", Output = "=== RUN TestFoo/Sub\n" }), + vim.json.encode({ Action = "fail", Test = "TestFoo/Sub" }), + }, nil) + end + if opts and opts.on_exit then + opts.on_exit(1, 1, nil) + end + return 1 + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/output_detail_focus_l_test.go") + vim.bo[bufnr].filetype = "go" + vim.api.nvim_set_current_buf(bufnr) + + test_samurai.test_file() + local out_buf = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(out_buf, 0, -1, false) + local target = nil + for i, line in ipairs(lines) do + if line:match("^%[ FAIL %] %- ") then + target = i + break + end + end + assert.is_not_nil(target) + vim.api.nvim_win_set_cursor(0, { target, 0 }) + + local output_win = vim.api.nvim_get_current_win() + core.open_test_output_at_cursor() + + local detail_win = find_detail_win(output_win) + assert.is_not_nil(detail_win) + vim.api.nvim_set_current_win(output_win) + + local keys = vim.api.nvim_replace_termcodes("", true, false, true) + vim.api.nvim_feedkeys(keys, "x", false) + vim.wait(20, function() + return vim.api.nvim_get_current_win() == detail_win + end) + + assert.equals(detail_win, vim.api.nvim_get_current_win()) + + vim.fn.jobstart = orig_jobstart + end) + + it("expands detail to full width and toggles with z", function() + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { + vim.json.encode({ Action = "output", Test = "TestFoo/Sub", Output = "=== RUN TestFoo/Sub\n" }), + vim.json.encode({ Action = "fail", Test = "TestFoo/Sub" }), + }, nil) + end + if opts and opts.on_exit then + opts.on_exit(1, 1, nil) + end + return 1 + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/output_detail_full_width_test.go") + vim.bo[bufnr].filetype = "go" + vim.api.nvim_set_current_buf(bufnr) + + test_samurai.test_file() + local out_buf = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(out_buf, 0, -1, false) + local target = nil + for i, line in ipairs(lines) do + if line:match("^%[ FAIL %] %- ") then + target = i + break + end + end + assert.is_not_nil(target) + vim.api.nvim_win_set_cursor(0, { target, 0 }) + + local output_win = vim.api.nvim_get_current_win() + core.open_test_output_at_cursor() + + local detail_win = find_detail_win(output_win) + assert.is_not_nil(detail_win) + local left_cfg = vim.api.nvim_win_get_config(output_win) + local right_cfg = vim.api.nvim_win_get_config(detail_win) + local total_width = left_cfg.width + right_cfg.width + + vim.api.nvim_set_current_win(output_win) + local leader_keys = vim.api.nvim_replace_termcodes("z", true, false, true) + vim.api.nvim_feedkeys(leader_keys, "x", false) + vim.wait(20, function() + local updated_left = vim.api.nvim_win_get_config(output_win) + return updated_left.width <= 1 + end) + + local collapsed_left = vim.api.nvim_win_get_config(output_win) + local expanded_right = vim.api.nvim_win_get_config(detail_win) + assert.is_true(collapsed_left.width <= 1) + assert.is_true(expanded_right.width >= total_width - 1) + + vim.api.nvim_set_current_win(detail_win) + local leader_toggle = vim.api.nvim_replace_termcodes("z", true, false, true) + vim.api.nvim_feedkeys(leader_toggle, "x", false) + vim.wait(20, function() + local updated_left = vim.api.nvim_win_get_config(output_win) + return updated_left.width > 1 + end) + + local toggle_left = vim.api.nvim_win_get_config(output_win) + local toggle_right = vim.api.nvim_win_get_config(detail_win) + local toggle_total = toggle_left.width + toggle_right.width + local toggle_expected = math.floor(toggle_total * 0.25) + assert.is_true(math.abs(toggle_left.width - toggle_expected) <= 1) + + vim.fn.jobstart = orig_jobstart + end) + + it("closes floats via ", function() + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { + vim.json.encode({ Action = "pass", Test = "TestFoo/Sub" }), + }, nil) + end + if opts and opts.on_exit then + opts.on_exit(1, 0, nil) + end + return 1 + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/output_detail_close_container_test.go") + vim.bo[bufnr].filetype = "go" + vim.api.nvim_set_current_buf(bufnr) + + test_samurai.test_file() + + local wins = find_float_wins() + assert.equals(1, #wins) + + local keys = vim.api.nvim_replace_termcodes("", true, false, true) + vim.api.nvim_feedkeys(keys, "x", false) + vim.wait(20, function() + return #find_float_wins() == 0 + end) + + local remaining = find_float_wins() + assert.equals(0, #remaining) + + vim.fn.jobstart = orig_jobstart + end) + + it("disables hardtime in listing/detail and restores on close", function() + local orig_hardtime = package.loaded["hardtime"] + local disable_calls = 0 + local enable_calls = 0 + local hardtime_state = true + local hardtime = { + is_plugin_enabled = true, + disable = function() + disable_calls = disable_calls + 1 + hardtime_state = false + hardtime.is_plugin_enabled = false + end, + enable = function() + enable_calls = enable_calls + 1 + hardtime_state = true + hardtime.is_plugin_enabled = true + end, + } + package.loaded["hardtime"] = hardtime + + local orig_jobstart = vim.fn.jobstart + vim.fn.jobstart = function(_cmd, opts) + if opts and opts.on_stdout then + opts.on_stdout(1, { + vim.json.encode({ Action = "output", Test = "TestFoo/Sub", Output = "=== RUN TestFoo/Sub\n" }), + vim.json.encode({ Action = "fail", Test = "TestFoo/Sub" }), + }, nil) + end + if opts and opts.on_exit then + opts.on_exit(1, 1, nil) + end + return 1 + end + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(bufnr, "/tmp/output_detail_hardtime_test.go") + vim.bo[bufnr].filetype = "go" + vim.api.nvim_set_current_buf(bufnr) + + test_samurai.test_file() + + local out_buf = vim.api.nvim_get_current_buf() + local lines = vim.api.nvim_buf_get_lines(out_buf, 0, -1, false) + local target = nil + for i, line in ipairs(lines) do + if line:match("^%[ FAIL %] %- ") then + target = i + break + end + end + assert.is_not_nil(target) + vim.api.nvim_win_set_cursor(0, { target, 0 }) + core.open_test_output_at_cursor() + + assert.is_true(disable_calls >= 1) + assert.is_false(hardtime_state) + + local keys = vim.api.nvim_replace_termcodes("", true, false, true) + vim.api.nvim_feedkeys(keys, "x", false) + vim.wait(50, function() + return #find_float_wins() == 0 + end) + + assert.equals(1, enable_calls) + assert.is_true(hardtime_state) + + vim.fn.jobstart = orig_jobstart + package.loaded["hardtime"] = orig_hardtime + end) + + it("closes detail when listing float is closed", function() local orig_jobstart = vim.fn.jobstart vim.fn.jobstart = function(_cmd, opts) if opts and opts.on_stdout then @@ -1726,7 +2315,7 @@ describe("test-samurai output detail view", function() vim.fn.jobstart = orig_jobstart end) - it("closes container when navigating out of floats", function() + it("keeps floats open when navigating out of floats", function() local orig_jobstart = vim.fn.jobstart vim.fn.jobstart = function(_cmd, opts) if opts and opts.on_stdout then @@ -1761,17 +2350,24 @@ describe("test-samurai output detail view", function() core.open_test_output_at_cursor() - local non_float = find_non_float_win() + local non_float = nil + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + local cfg = vim.api.nvim_win_get_config(win) + if cfg.relative == "" then + non_float = win + break + end + end assert.is_not_nil(non_float) vim.api.nvim_set_current_win(non_float) local remaining = find_float_wins() - assert.equals(0, #remaining) + assert.equals(2, #remaining) vim.fn.jobstart = orig_jobstart end) - it("keeps container open when focusing listing from detail", function() + it("keeps floats open when focusing listing from detail", function() local orig_jobstart = vim.fn.jobstart vim.fn.jobstart = function(_cmd, opts) if opts and opts.on_stdout then @@ -1808,13 +2404,7 @@ describe("test-samurai output detail view", function() core.open_test_output_at_cursor() local wins = find_float_wins() - local detail_win = nil - for _, win in ipairs(wins) do - if win ~= output_win then - detail_win = win - break - end - end + local detail_win = find_detail_win(output_win) assert.is_not_nil(detail_win) vim.api.nvim_set_current_win(detail_win)