local viewer = require 'std.dbg.viewer' local file = require 'std.dbg.file' local encode = require 'std.dbg.encode' describe( 'std.dbg.viewer', function () local test_log_path local original_input before_each( function () package.loaded['std.dbg.viewer'] = nil viewer = require 'std.dbg.viewer' test_log_path = file.get_path() file.clear_log() vim.g.bugabinga_debug_mode = true original_input = vim.ui.input rawset(vim.ui, 'input', function( _, on_choice ) on_choice( nil ) end) end ) after_each( function () pcall(viewer.close) file.clear_log() vim.g.bugabinga_debug_mode = false rawset(vim.ui, 'input', original_input) end ) local function write_event(event) local f = assert(io.open(test_log_path, 'a')) f:write(encode.encode(event) .. '\n') f:close() end local function make_event(overrides) local e = { kind = 'DEBUG', date = os.time(), message = 'test message', data = { 'test' }, filename = 'test.lua', fn = 'test_fn', line = 10, } if overrides then for k, v in pairs(overrides) do e[k] = v end end return e end describe( 'Phase 1: lifecycle', function () it( 'open() creates a split window', function () write_event(make_event()) viewer.open() assert.is_true(viewer.is_open()) local wins = vim.api.nvim_list_wins() assert.is_true(#wins > 1) end ) it( 'open() creates a scratch buffer', function () write_event(make_event()) viewer.open() local buf_id = vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()) assert.equal('nofile', vim.bo[buf_id].buftype) assert.equal(false, vim.bo[buf_id].buflisted) end ) it( 'open() sets buffer filetype to DbgViewer', function () write_event(make_event()) viewer.open() local buf_id = vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()) assert.equal('DbgViewer', vim.bo[buf_id].filetype) end ) it( 'close() closes the window', function () write_event(make_event()) viewer.open() local win_id = vim.api.nvim_get_current_win() viewer.close() assert.is_false(vim.api.nvim_win_is_valid(win_id)) end ) it( 'close() wipes the buffer', function () write_event(make_event()) viewer.open() local buf_id = vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()) viewer.close() assert.is_false(vim.api.nvim_buf_is_valid(buf_id)) end ) it( 'is_open() returns false initially', function () assert.is_false(viewer.is_open()) end ) it( 'is_open() returns true after open()', function () write_event(make_event()) viewer.open() assert.is_true(viewer.is_open()) end ) it( 'open() reuses existing window', function () write_event(make_event()) viewer.open() local first_win = vim.api.nvim_get_current_win() viewer.open() local second_win = vim.api.nvim_get_current_win() assert.equal(first_win, second_win) end ) end ) describe( 'Phase 2: rendering', function () it( 'render() shows kind icon in header', function () write_event(make_event({ kind = 'DEBUG', message = 'hello' })) viewer.open() local lines = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()), 0, -1, false) assert.is_not_nil(lines[1]:match 'DEBUG') end ) it( 'render() shows relative time', function () write_event(make_event({ date = os.time() - 120, message = 'time test' })) viewer.open() local lines = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()), 0, -1, false) assert.is_not_nil(lines[1]:match 'mi') end ) it( 'render() shows message', function () write_event(make_event({ message = 'my special message' })) viewer.open() local lines = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()), 0, -1, false) local found = false for _, l in ipairs(lines) do if l:match 'my special message' then found = true break end end assert.is_true(found) end ) it( 'render() shows source location', function () write_event(make_event({ filename = 'myfile.lua', line = 42, message = 'loc test' })) viewer.open() local lines = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()), 0, -1, false) local found = false for _, l in ipairs(lines) do if l:match 'myfile%.lua.*42' then found = true break end end assert.is_true(found) end ) it( 'render() separates events with blank lines', function () write_event(make_event({ message = 'ev1' })) write_event(make_event({ message = 'ev2' })) viewer.open() local lines = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()), 0, -1, false) local blank_count = 0 for _, l in ipairs(lines) do if l == '' then blank_count = blank_count + 1 end end assert.is_true(blank_count >= 1) end ) it( 'render() handles empty events', function () viewer.open() local lines = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()), 0, -1, false) assert.is_not_nil(lines[1]) end ) it( 'render() applies kind highlight groups via namespace', function () write_event(make_event({ kind = 'ERROR', message = 'err test' })) viewer.open() local buf_id = vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()) local hls = vim.api.nvim_buf_get_extmarks(buf_id, -1, 0, -1, { details = true }) assert.is_true(#hls > 0) end ) end ) describe( 'Phase 3: filtering', function () it( 'filter_by_kind filters events', function () write_event(make_event({ kind = 'DEBUG', message = 'debug msg' })) write_event(make_event({ kind = 'ERROR', message = 'error msg' })) viewer.open() viewer.clear_filters() local lines = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()), 0, -1, false) local debug_count = 0 for _, l in ipairs(lines) do if l:match 'DEBUG' then debug_count = debug_count + 1 end end local error_count = 0 for _, l in ipairs(lines) do if l:match 'ERROR' then error_count = error_count + 1 end end assert.is_true(debug_count >= 1) assert.is_true(error_count >= 1) end ) it( 'filter_by_message filters events', function () write_event(make_event({ message = 'unique_debug_msg_xyz' })) write_event(make_event({ message = 'other_msg' })) viewer.open() viewer.clear_filters() local lines = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()), 0, -1, false) local found = false for _, l in ipairs(lines) do if l:match 'unique_debug_msg_xyz' then found = true break end end assert.is_true(found) end ) it( 'filter_by_location filters events', function () write_event(make_event({ filename = 'special_file.lua', message = 'file1' })) write_event(make_event({ filename = 'other.lua', message = 'file2' })) viewer.open() viewer.clear_filters() local lines = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()), 0, -1, false) local found = false for _, l in ipairs(lines) do if l:match 'special_file' then found = true break end end assert.is_true(found) end ) it( 'clear_filters shows all events', function () write_event(make_event({ message = 'msg1' })) write_event(make_event({ message = 'msg2' })) viewer.open() viewer.clear_filters() local lines = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()), 0, -1, false) local msg1, msg2 = false, false for _, l in ipairs(lines) do if l:match 'msg1' then msg1 = true end if l:match 'msg2' then msg2 = true end end assert.is_true(msg1 and msg2) end ) it( 'multiple filters combine with AND', function () write_event(make_event({ kind = 'ERROR', message = 'err1', filename = 'a.lua' })) write_event(make_event({ kind = 'ERROR', message = 'err2', filename = 'b.lua' })) write_event(make_event({ kind = 'DEBUG', message = 'dbg1', filename = 'a.lua' })) viewer.open() viewer.clear_filters() local lines = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()), 0, -1, false) local err_count = 0 for _, l in ipairs(lines) do if l:match 'ERROR' then err_count = err_count + 1 end end assert.is_true(err_count >= 2) end ) end ) describe( 'Phase 4: keymaps', function () it( 'q closes viewer', function () write_event(make_event()) viewer.open() local win_id = vim.api.nvim_get_current_win() vim.api.nvim_win_call(win_id, function() vim.cmd 'normal q' end) assert.is_false(viewer.is_open()) end ) it( ' closes viewer', function () write_event(make_event()) viewer.open() viewer.close() assert.is_false(viewer.is_open()) end ) it( 'r refreshes events from disk', function () write_event(make_event({ message = 'original' })) viewer.open() write_event(make_event({ message = 'newly_added' })) local win_id = vim.api.nvim_get_current_win() vim.api.nvim_win_call(win_id, function() vim.cmd 'normal r' end) local lines = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(win_id), 0, -1, false) local found = false for _, l in ipairs(lines) do if l:match 'newly_added' then found = true break end end assert.is_true(found) end ) it( ' jumps to source', function () local jump_path = vim.fn.fnamemodify(vim.fn.getcwd(), ':p') .. 'test_jump_target.lua' local f = assert(io.open(jump_path, 'w')) f:write 'local x = 1\nlocal y = 2\n' f:close() write_event(make_event({ filename = jump_path, line = 2, message = 'jump test' })) viewer.open() local viewer_win = vim.api.nvim_get_current_win() vim.api.nvim_win_set_cursor(viewer_win, { 2, 0 }) local curbuf_before = vim.api.nvim_get_current_buf() vim.api.nvim_win_call(viewer_win, function() viewer.jump_to_source(viewer_win) end) local curbuf_after = vim.api.nvim_get_current_buf() local name = vim.api.nvim_buf_get_name(curbuf_after) assert(name ~= nil, 'buffer should have a name after jump') assert(name:match 'test_jump_target' ~= nil, 'should jump to test file: ' .. tostring(name)) vim.fn.delete(jump_path) end ) it( 'ff filters combined (all)', function () write_event(make_event({ kind = 'DEBUG', message = 'foo' })) write_event(make_event({ kind = 'ERROR', message = 'bar' })) viewer.open() viewer.clear_filters() local lines_before = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()), 0, -1, false) local foo_before = false for _, l in ipairs(lines_before) do if l:match 'foo' then foo_before = true break end end assert.is_true(foo_before) local win_id = vim.api.nvim_get_current_win() vim.api.nvim_win_call(win_id, function() vim.cmd 'normal ff' end) assert.is_true(viewer.is_open()) end ) it( 'fc clears filters and notifies', function () write_event(make_event({ message = 'fc_test' })) viewer.open() local win_id = vim.api.nvim_get_current_win() vim.api.nvim_win_call(win_id, function() vim.cmd 'normal fc' end) assert.is_true(viewer.is_open()) end ) end ) describe( 'Phase 5: edge cases', function () it( 'handles events with missing fields gracefully', function () local f = assert(io.open(test_log_path, 'a')) f:write(vim.json.encode({ kind = 'DEBUG', message = 'partial' }) .. '\n') f:close() viewer.open() assert.is_true(viewer.is_open()) end ) it( 'handles very long messages', function () local long_msg = string.rep('x', 1000) write_event(make_event({ message = long_msg })) viewer.open() local lines = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()), 0, -1, false) assert.is_true(#lines > 0) end ) it( 'handles many events without blocking', function () for i = 1, 50 do write_event(make_event({ message = 'event ' .. i })) end local ok = pcall(function() viewer.open() end) assert.is_true(ok) assert.is_true(viewer.is_open()) end ) it( 'jump_to_source jumps to correct file after auto-refresh prepend', function () local path1 = vim.fn.fnamemodify(vim.fn.getcwd(), ':p') .. 'jump_test_1.lua' local path2 = vim.fn.fnamemodify(vim.fn.getcwd(), ':p') .. 'jump_test_2.lua' local path3 = vim.fn.fnamemodify(vim.fn.getcwd(), ':p') .. 'jump_test_3.lua' local path4 = vim.fn.fnamemodify(vim.fn.getcwd(), ':p') .. 'jump_test_4.lua' for _, p in ipairs({ path1, path2, path3, path4 }) do local f = assert(io.open(p, 'w')) f:write('line1\nline2\nline3\nline4\nline5\n') f:close() end write_event(make_event({ filename = path1, line = 2, message = 'event1' })) write_event(make_event({ filename = path2, line = 3, message = 'event2' })) viewer.open() write_event(make_event({ filename = path3, line = 4, message = 'event3' })) write_event(make_event({ filename = path4, line = 5, message = 'event4' })) viewer._on_file_change() local viewer_win = vim.api.nvim_get_current_win() vim.api.nvim_win_set_cursor(viewer_win, { 1, 0 }) vim.api.nvim_win_call(viewer_win, function() viewer.jump_to_source(viewer_win) end) local curbuf = vim.api.nvim_get_current_buf() local name = vim.api.nvim_buf_get_name(curbuf) assert(name:match 'jump_test_4' ~= nil, 'should jump to newest event (path4), got: ' .. tostring(name)) for _, p in ipairs({ path1, path2, path3, path4 }) do vim.fn.delete(p) end end ) it( 'watcher remains alive after auto-refresh', function () write_event(make_event({ message = 'e1' })) viewer.open() assert(assert(viewer._get_state()).watcher, 'watcher should exist after open') write_event(make_event({ message = 'e2' })) viewer._on_file_change() assert(assert(viewer._get_state()).watcher, 'watcher should still exist after file change') end ) it( 'original event highlights survive auto-refresh', function () write_event(make_event({ kind = 'ERROR', message = 'event1' })) write_event(make_event({ kind = 'DEBUG', message = 'event2' })) viewer.open() local buf_id = vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()) local extmarks_before = vim.api.nvim_buf_get_extmarks(buf_id, -1, 0, -1, { details = true }) assert(#extmarks_before > 0, 'should have highlights on initial render') write_event(make_event({ kind = 'TRACE', message = 'event3' })) viewer._on_file_change() local extmarks_after = vim.api.nvim_buf_get_extmarks(buf_id, -1, 0, -1, { details = true }) assert(#extmarks_after >= #extmarks_before, 'should retain original highlights after prepend') end ) it( 'sanitizes [string "..."] source to unknown', function () local chunk_event = { kind = 'DEBUG', date = os.time(), message = 'chunk test', data = {}, filename = '[string "chunk:123"]', fn = 'test_fn', line = 10, } write_event(chunk_event) viewer.open() local lines = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(vim.api.nvim_get_current_win()), 0, -1, false) local found_unknown = false for _, l in ipairs(lines) do if l:match 'unknown' then found_unknown = true break end end assert(found_unknown, 'should show "unknown" for [string "..."] source') end ) it( 'filtered_indices stay consistent after auto-refresh', function () write_event(make_event({ kind = 'DEBUG', message = 'debug1' })) viewer.open() viewer.clear_filters() local state_before = assert(viewer._get_state()) local filtered_count_before = state_before.filtered_count write_event(make_event({ kind = 'DEBUG', message = 'debug2' })) viewer._on_file_change() local state_after = assert(viewer._get_state()) assert.equal(filtered_count_before + 1, state_after.filtered_count, 'filtered_count should increment for new matching event') end ) end ) end )