From 8899838e15e2ca0a89bf21463150184adfdd6c0c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 28 Mar 2026 15:05:55 -0400 Subject: [PATCH] feat: tests and better vim validate --- .busted | 9 + forge.nvim-scm-1.rockspec | 9 + lua/forge/init.lua | 81 ++++++++- spec/init_spec.lua | 355 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 451 insertions(+), 3 deletions(-) create mode 100644 .busted create mode 100644 spec/init_spec.lua diff --git a/.busted b/.busted new file mode 100644 index 0000000..53513b8 --- /dev/null +++ b/.busted @@ -0,0 +1,9 @@ +return { + _all = { + lua = 'nvim -l', + ROOT = { './spec/' }, + }, + default = { + verbose = true, + }, +} diff --git a/forge.nvim-scm-1.rockspec b/forge.nvim-scm-1.rockspec index 09f9368..7332245 100644 --- a/forge.nvim-scm-1.rockspec +++ b/forge.nvim-scm-1.rockspec @@ -16,6 +16,15 @@ dependencies = { 'lua >= 5.1', } +test_dependencies = { + 'nlua', + 'busted >= 2.1.1', +} + +test = { + type = 'busted', +} + build = { type = 'builtin', } diff --git a/lua/forge/init.lua b/lua/forge/init.lua index 8682f9e..52b2405 100644 --- a/lua/forge/init.lua +++ b/lua/forge/init.lua @@ -720,12 +720,87 @@ function M.config() return v == false or type(v) == 'table' end, 'table or false') vim.validate('forge.display', cfg.display, 'table') - vim.validate('forge.display.icons', cfg.display.icons, 'table') - vim.validate('forge.display.widths', cfg.display.widths, 'table') - vim.validate('forge.display.limits', cfg.display.limits, 'table') vim.validate('forge.ci', cfg.ci, 'table') vim.validate('forge.ci.lines', cfg.ci.lines, 'number') + vim.validate('forge.display.icons', cfg.display.icons, 'table') + vim.validate('forge.display.icons.open', cfg.display.icons.open, 'string') + vim.validate('forge.display.icons.merged', cfg.display.icons.merged, 'string') + vim.validate('forge.display.icons.closed', cfg.display.icons.closed, 'string') + vim.validate('forge.display.icons.pass', cfg.display.icons.pass, 'string') + vim.validate('forge.display.icons.fail', cfg.display.icons.fail, 'string') + vim.validate('forge.display.icons.pending', cfg.display.icons.pending, 'string') + vim.validate('forge.display.icons.skip', cfg.display.icons.skip, 'string') + vim.validate('forge.display.icons.unknown', cfg.display.icons.unknown, 'string') + + vim.validate('forge.display.widths', cfg.display.widths, 'table') + vim.validate('forge.display.widths.title', cfg.display.widths.title, 'number') + vim.validate('forge.display.widths.author', cfg.display.widths.author, 'number') + vim.validate('forge.display.widths.name', cfg.display.widths.name, 'number') + vim.validate('forge.display.widths.branch', cfg.display.widths.branch, 'number') + + vim.validate('forge.display.limits', cfg.display.limits, 'table') + vim.validate('forge.display.limits.pulls', cfg.display.limits.pulls, 'number') + vim.validate('forge.display.limits.issues', cfg.display.limits.issues, 'number') + vim.validate('forge.display.limits.runs', cfg.display.limits.runs, 'number') + + local key_or_false = function(v) + return v == false or type(v) == 'string' + end + if type(cfg.keys) == 'table' then + local keys = cfg.keys --[[@as forge.KeysConfig]] + if keys.pr ~= nil then + vim.validate('forge.keys.pr', keys.pr, 'table') + for _, k in ipairs({ + 'checkout', + 'diff', + 'worktree', + 'ci', + 'browse', + 'manage', + 'create', + 'filter', + 'refresh', + }) do + vim.validate('forge.keys.pr.' .. k, keys.pr[k], key_or_false, 'string or false') + end + end + if keys.issue ~= nil then + vim.validate('forge.keys.issue', keys.issue, 'table') + for _, k in ipairs({ 'browse', 'close', 'filter', 'refresh' }) do + vim.validate('forge.keys.issue.' .. k, keys.issue[k], key_or_false, 'string or false') + end + end + if keys.ci ~= nil then + vim.validate('forge.keys.ci', keys.ci, 'table') + for _, k in ipairs({ 'log', 'browse', 'failed', 'passed', 'running', 'all', 'refresh' }) do + vim.validate('forge.keys.ci.' .. k, keys.ci[k], key_or_false, 'string or false') + end + end + if keys.commits ~= nil then + vim.validate('forge.keys.commits', keys.commits, 'table') + for _, k in ipairs({ 'checkout', 'diff', 'browse', 'yank' }) do + vim.validate( + 'forge.keys.commits.' .. k, + keys.commits[k], + key_or_false, + 'string or false' + ) + end + end + if keys.branches ~= nil then + vim.validate('forge.keys.branches', keys.branches, 'table') + for _, k in ipairs({ 'diff', 'browse' }) do + vim.validate( + 'forge.keys.branches.' .. k, + keys.branches[k], + key_or_false, + 'string or false' + ) + end + end + end + for name, source in pairs(cfg.sources) do vim.validate('forge.sources.' .. name, source, 'table') if source.hosts ~= nil then diff --git a/spec/init_spec.lua b/spec/init_spec.lua new file mode 100644 index 0000000..5702349 --- /dev/null +++ b/spec/init_spec.lua @@ -0,0 +1,355 @@ +vim.opt.runtimepath:prepend(vim.fn.getcwd()) + +package.preload['fzf-lua.utils'] = function() + return { + ansi_from_hl = function(_, text) + return text + end, + } +end + +local forge = require('forge') + +describe('config', function() + after_each(function() + vim.g.forge = nil + end) + + it('returns defaults when vim.g.forge is nil', function() + vim.g.forge = nil + local cfg = forge.config() + assert.equals(10000, cfg.ci.lines) + assert.equals(45, cfg.display.widths.title) + assert.equals(100, cfg.display.limits.pulls) + assert.equals('+', cfg.display.icons.open) + assert.equals('', cfg.keys.pr.checkout) + end) + + it('deep-merges partial user config', function() + vim.g.forge = { ci = { lines = 500 }, display = { icons = { open = '>' } } } + local cfg = forge.config() + assert.equals(500, cfg.ci.lines) + assert.equals('>', cfg.display.icons.open) + assert.equals('m', cfg.display.icons.merged) + assert.equals(45, cfg.display.widths.title) + end) + + it('sets keys to false when user requests it', function() + vim.g.forge = { keys = false } + local cfg = forge.config() + assert.is_false(cfg.keys) + end) +end) + +describe('format_pr', function() + local fields = { + number = 'number', + title = 'title', + state = 'state', + author = 'login', + created_at = 'created_at', + } + + it('formats open PR with state icon', function() + local entry = + { number = 42, title = 'fix bug', state = 'OPEN', login = 'alice', created_at = '' } + local result = forge.format_pr(entry, fields, true) + assert.truthy(result:find('+')) + assert.truthy(result:find('#42')) + assert.truthy(result:find('fix bug')) + end) + + it('formats merged PR', function() + local entry = + { number = 7, title = 'add feature', state = 'MERGED', login = 'bob', created_at = '' } + local result = forge.format_pr(entry, fields, true) + assert.truthy(result:find('m')) + assert.truthy(result:find('#7')) + end) + + it('formats closed PR', function() + local entry = { number = 3, title = 'stale', state = 'CLOSED', login = 'eve', created_at = '' } + local result = forge.format_pr(entry, fields, true) + assert.truthy(result:find('x')) + end) + + it('omits state prefix when show_state is false', function() + local entry = { number = 1, title = 'no state', state = 'OPEN', login = 'dev', created_at = '' } + local result = forge.format_pr(entry, fields, false) + assert.truthy(result:find('#1')) + assert.falsy(result:match('^+')) + end) + + it('truncates long titles', function() + local long_title = string.rep('a', 100) + local entry = { number = 9, title = long_title, state = 'OPEN', login = 'x', created_at = '' } + local result = forge.format_pr(entry, fields, false) + assert.falsy(result:find(long_title)) + end) + + it('extracts author from table with login field', function() + local entry = + { number = 5, title = 't', state = 'OPEN', login = { login = 'nested' }, created_at = '' } + local result = forge.format_pr(entry, fields, false) + assert.truthy(result:find('nested')) + end) +end) + +describe('format_issue', function() + local fields = { + number = 'number', + title = 'title', + state = 'state', + author = 'author', + created_at = 'created_at', + } + + it('formats open issue', function() + local entry = + { number = 10, title = 'bug report', state = 'open', author = 'alice', created_at = '' } + local result = forge.format_issue(entry, fields, true) + assert.truthy(result:find('+')) + assert.truthy(result:find('#10')) + end) + + it('formats closed issue', function() + local entry = { number = 11, title = 'done', state = 'closed', author = 'bob', created_at = '' } + local result = forge.format_issue(entry, fields, true) + assert.truthy(result:find('x')) + end) + + it('handles opened state (GitLab)', function() + local entry = + { number = 12, title = 'mr issue', state = 'opened', author = 'c', created_at = '' } + local result = forge.format_issue(entry, fields, true) + assert.truthy(result:find('+')) + end) +end) + +describe('format_check', function() + it('maps pass bucket', function() + local result = forge.format_check({ name = 'lint', bucket = 'pass' }) + assert.truthy(result:find('%*')) + assert.truthy(result:find('lint')) + end) + + it('maps fail bucket', function() + local result = forge.format_check({ name = 'build', bucket = 'fail' }) + assert.truthy(result:find('x')) + end) + + it('maps pending bucket', function() + local result = forge.format_check({ name = 'test', bucket = 'pending' }) + assert.truthy(result:find('~')) + end) + + it('maps skipping bucket', function() + local result = forge.format_check({ name = 'optional', bucket = 'skipping' }) + assert.truthy(result:find('%-')) + end) + + it('maps cancel bucket', function() + local result = forge.format_check({ name = 'cancelled', bucket = 'cancel' }) + assert.truthy(result:find('%-')) + end) + + it('maps unknown bucket', function() + local result = forge.format_check({ name = 'mystery', bucket = 'something_else' }) + assert.truthy(result:find('%?')) + end) + + it('defaults to pending when bucket is nil', function() + local result = forge.format_check({ name = 'none' }) + assert.truthy(result:find('~')) + end) +end) + +describe('format_run', function() + it('formats successful run with branch', function() + local run = + { name = 'CI', branch = 'main', status = 'success', event = 'push', created_at = '' } + local result = forge.format_run(run) + assert.truthy(result:find('%*')) + assert.truthy(result:find('CI')) + assert.truthy(result:find('main')) + assert.truthy(result:find('push')) + end) + + it('formats failed run without branch', function() + local run = { + name = 'Deploy', + branch = '', + status = 'failure', + event = 'workflow_dispatch', + created_at = '', + } + local result = forge.format_run(run) + assert.truthy(result:find('x')) + assert.truthy(result:find('manual')) + end) + + it('maps in_progress status', function() + local run = + { name = 'Test', branch = '', status = 'in_progress', event = 'push', created_at = '' } + local result = forge.format_run(run) + assert.truthy(result:find('~')) + end) + + it('maps cancelled status', function() + local run = { name = 'Old', branch = '', status = 'cancelled', event = 'push', created_at = '' } + local result = forge.format_run(run) + assert.truthy(result:find('%-')) + end) +end) + +describe('filter_checks', function() + local checks = { + { name = 'a', bucket = 'pass' }, + { name = 'b', bucket = 'fail' }, + { name = 'c', bucket = 'pending' }, + { name = 'd', bucket = 'skipping' }, + } + + it('returns all checks sorted by severity when filter is nil', function() + local result = forge.filter_checks(vim.deepcopy(checks), nil) + assert.equals(4, #result) + assert.equals('b', result[1].name) + assert.equals('c', result[2].name) + assert.equals('a', result[3].name) + assert.equals('d', result[4].name) + end) + + it('returns all checks when filter is "all"', function() + local result = forge.filter_checks(vim.deepcopy(checks), 'all') + assert.equals(4, #result) + end) + + it('filters to specific bucket', function() + local result = forge.filter_checks(vim.deepcopy(checks), 'fail') + assert.equals(1, #result) + assert.equals('b', result[1].name) + end) + + it('returns empty when no matches', function() + local result = forge.filter_checks(vim.deepcopy(checks), 'cancel') + assert.equals(0, #result) + end) +end) + +describe('relative_time via format_pr', function() + local fields = { number = 'n', title = 't', state = 's', author = 'a', created_at = 'ts' } + + it('shows minutes for recent timestamps', function() + local ts = os.date('%Y-%m-%dT%H:%M:%SZ', os.time() - 120) + local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = ts } + local result = forge.format_pr(entry, fields, false) + assert.truthy(result:match('%d+m')) + end) + + it('shows hours', function() + local ts = os.date('%Y-%m-%dT%H:%M:%SZ', os.time() - 7200) + local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = ts } + local result = forge.format_pr(entry, fields, false) + assert.truthy(result:match('%d+h')) + end) + + it('shows days', function() + local ts = os.date('%Y-%m-%dT%H:%M:%SZ', os.time() - 172800) + local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = ts } + local result = forge.format_pr(entry, fields, false) + assert.truthy(result:match('%d+d')) + end) + + it('returns empty for nil timestamp', function() + local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = nil } + local result = forge.format_pr(entry, fields, false) + assert.truthy(result) + end) + + it('returns empty for empty string timestamp', function() + local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = '' } + local result = forge.format_pr(entry, fields, false) + assert.truthy(result) + end) + + it('returns empty for garbage timestamp', function() + local entry = { n = 1, t = 'x', s = 'open', a = 'u', ts = 'not-a-date' } + local result = forge.format_pr(entry, fields, false) + assert.truthy(result) + end) +end) + +describe('config validation', function() + after_each(function() + vim.g.forge = nil + end) + + it('rejects non-table sources', function() + vim.g.forge = { sources = 'bad' } + assert.has_error(function() + forge.config() + end) + end) + + it('rejects non-table display', function() + vim.g.forge = { display = 42 } + assert.has_error(function() + forge.config() + end) + end) + + it('rejects non-number ci.lines', function() + vim.g.forge = { ci = { lines = 'many' } } + assert.has_error(function() + forge.config() + end) + end) + + it('rejects non-string icon', function() + vim.g.forge = { display = { icons = { open = 123 } } } + assert.has_error(function() + forge.config() + end) + end) + + it('rejects non-number width', function() + vim.g.forge = { display = { widths = { title = 'wide' } } } + assert.has_error(function() + forge.config() + end) + end) + + it('rejects non-number limit', function() + vim.g.forge = { display = { limits = { pulls = true } } } + assert.has_error(function() + forge.config() + end) + end) + + it('rejects non-string key binding', function() + vim.g.forge = { keys = { pr = { checkout = 42 } } } + assert.has_error(function() + forge.config() + end) + end) + + it('accepts false for individual key binding', function() + vim.g.forge = { keys = { pr = { checkout = false } } } + local cfg = forge.config() + assert.is_false(cfg.keys.pr.checkout) + end) + + it('rejects keys as a string', function() + vim.g.forge = { keys = 'none' } + assert.has_error(function() + forge.config() + end) + end) + + it('rejects non-table source hosts', function() + vim.g.forge = { sources = { custom = { hosts = 99 } } } + assert.has_error(function() + forge.config() + end) + end) +end)