diff --git a/spec/file_spec.lua b/spec/file_spec.lua new file mode 100644 index 0000000..9f9a364 --- /dev/null +++ b/spec/file_spec.lua @@ -0,0 +1,348 @@ +require('spec.helpers') + +local config = require('pending.config') +local parse = require('pending.parse') +local store = require('pending.store') +local diff = require('pending.diff') +local views = require('pending.views') + +describe('file token', function() + local tmpdir + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + config.reset() + store.unload() + store.load() + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + vim.g.pending = nil + config.reset() + store.unload() + end) + + describe('parse.body', function() + it('extracts file token with path and line number', function() + local desc, meta = parse.body('Fix the bug file:src/auth.lua:42') + assert.are.equal('Fix the bug', desc) + assert.are.equal('src/auth.lua:42', meta.file) + end) + + it('extracts file token with nested path', function() + local desc, meta = parse.body('Do something file:lua/pending/init.lua:100') + assert.are.equal('Do something', desc) + assert.are.equal('lua/pending/init.lua:100', meta.file) + end) + + it('strips file token from description', function() + local desc, meta = parse.body('Task description file:foo.lua:1') + assert.are.equal('Task description', desc) + assert.are.equal('foo.lua:1', meta.file) + end) + + it('stops parsing on duplicate file token', function() + local desc, meta = parse.body('Task file:b.lua:2 file:a.lua:1') + assert.are.equal('Task file:b.lua:2', desc) + assert.are.equal('a.lua:1', meta.file) + end) + + it('treats malformed file token (no line number) as non-metadata', function() + local desc, meta = parse.body('Task file:nolineno') + assert.are.equal('Task file:nolineno', desc) + assert.is_nil(meta.file) + end) + + it('treats file: prefix with no path as non-metadata', function() + local desc, meta = parse.body('Task file:') + assert.are.equal('Task file:', desc) + assert.is_nil(meta.file) + end) + + it('handles file token alongside other metadata tokens', function() + local desc, meta = parse.body('Task cat:Work file:src/main.lua:10') + assert.are.equal('Task', desc) + assert.are.equal('Work', meta.cat) + assert.are.equal('src/main.lua:10', meta.file) + end) + + it('does not extract file token when line number is not numeric', function() + local desc, meta = parse.body('Task file:src/foo.lua:abc') + assert.are.equal('Task file:src/foo.lua:abc', desc) + assert.is_nil(meta.file) + end) + end) + + describe('diff reconciliation', function() + it('stores file field in _extra on write', function() + local t = store.add({ description = 'Task one' }) + store.save() + local lines = { + '/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42', + } + diff.apply(lines) + local updated = store.get(t.id) + assert.is_not_nil(updated._extra) + assert.are.equal('src/auth.lua:42', updated._extra.file) + end) + + it('updates file field when token changes', function() + local t = store.add({ description = 'Task one', _extra = { file = 'old.lua:1' } }) + store.save() + local lines = { + '/' .. t.id .. '/- [ ] Task one file:new.lua:99', + } + diff.apply(lines) + local updated = store.get(t.id) + assert.are.equal('new.lua:99', updated._extra.file) + end) + + it('clears file field when token is removed from line', function() + local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } }) + store.save() + local lines = { + '/' .. t.id .. '/- [ ] Task one', + } + diff.apply(lines) + local updated = store.get(t.id) + assert.is_nil(updated._extra) + end) + + it('preserves other _extra fields when file is cleared', function() + local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc123' } }) + store.save() + local lines = { + '/' .. t.id .. '/- [ ] Task one', + } + diff.apply(lines) + local updated = store.get(t.id) + assert.is_not_nil(updated._extra) + assert.is_nil(updated._extra.file) + assert.are.equal('abc123', updated._extra._gcal_event_id) + end) + + it('round-trips file field through JSON', function() + local t = store.add({ description = 'Task one' }) + store.save() + local lines = { + '/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42', + } + diff.apply(lines) + store.unload() + store.load() + local loaded = store.get(t.id) + assert.is_not_nil(loaded._extra) + assert.are.equal('src/auth.lua:42', loaded._extra.file) + end) + + it('accepts optional hidden_ids parameter without error', function() + local t = store.add({ description = 'Task one' }) + store.save() + local lines = { + '/' .. t.id .. '/- [ ] Task one', + } + assert.has_no_error(function() + diff.apply(lines, {}) + end) + end) + end) + + describe('LineMeta', function() + it('category_view populates file field in LineMeta', function() + local t = store.add({ + description = 'Task one', + _extra = { file = 'src/auth.lua:42' }, + }) + store.save() + local tasks = store.active_tasks() + local _, meta = views.category_view(tasks) + local task_meta = nil + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + break + end + end + assert.is_not_nil(task_meta) + assert.are.equal('src/auth.lua:42', task_meta.file) + end) + + it('priority_view populates file field in LineMeta', function() + local t = store.add({ + description = 'Task one', + _extra = { file = 'src/auth.lua:42' }, + }) + store.save() + local tasks = store.active_tasks() + local _, meta = views.priority_view(tasks) + local task_meta = nil + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + break + end + end + assert.is_not_nil(task_meta) + assert.are.equal('src/auth.lua:42', task_meta.file) + end) + + it('file field is nil in LineMeta when task has no file', function() + local t = store.add({ description = 'Task one' }) + store.save() + local tasks = store.active_tasks() + local _, meta = views.category_view(tasks) + local task_meta = nil + for _, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_meta = m + break + end + end + assert.is_not_nil(task_meta) + assert.is_nil(task_meta.file) + end) + end) + + describe(':Pending edit -file', function() + it('clears file reference from task', function() + local pending = require('pending') + local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } }) + store.save() + pending.edit(tostring(t.id), '-file') + local updated = store.get(t.id) + assert.is_nil(updated._extra) + end) + + it('shows feedback when file reference is removed', function() + local pending = require('pending') + local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } }) + store.save() + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, level) + table.insert(messages, { msg = msg, level = level }) + end + pending.edit(tostring(t.id), '-file') + vim.notify = orig_notify + assert.are.equal(1, #messages) + assert.truthy(messages[1].msg:find('file reference removed')) + end) + + it('does not error when task has no file', function() + local pending = require('pending') + local t = store.add({ description = 'Task one' }) + store.save() + assert.has_no_error(function() + pending.edit(tostring(t.id), '-file') + end) + end) + + it('preserves other _extra fields when -file is used', function() + local pending = require('pending') + local t = store.add({ + description = 'Task one', + _extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc' }, + }) + store.save() + pending.edit(tostring(t.id), '-file') + local updated = store.get(t.id) + assert.is_not_nil(updated._extra) + assert.is_nil(updated._extra.file) + assert.are.equal('abc', updated._extra._gcal_event_id) + end) + end) + + describe('goto_file', function() + it('notifies warn when task has no file attached', function() + local pending = require('pending') + local buffer = require('pending.buffer') + + local t = store.add({ description = 'Task one' }) + store.save() + + local bufnr = buffer.open() + vim.bo[bufnr].filetype = 'pending' + vim.api.nvim_set_current_buf(bufnr) + + local meta = buffer.meta() + local task_lnum = nil + for lnum, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_lnum = lnum + break + end + end + assert.is_not_nil(task_lnum) + vim.api.nvim_win_set_cursor(0, { task_lnum, 0 }) + + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, level) + table.insert(messages, { msg = msg, level = level }) + end + + pending.goto_file() + + vim.notify = orig_notify + + local warned = false + for _, m in ipairs(messages) do + if m.level == vim.log.levels.WARN and m.msg:find('No file attached') then + warned = true + end + end + assert.is_true(warned) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('notifies error when file spec is unreadable', function() + local pending = require('pending') + local buffer = require('pending.buffer') + + local t = store.add({ + description = 'Task one', + _extra = { file = 'nonexistent/path.lua:1' }, + }) + store.save() + + local bufnr = buffer.open() + vim.bo[bufnr].filetype = 'pending' + vim.api.nvim_set_current_buf(bufnr) + + local meta = buffer.meta() + local task_lnum = nil + for lnum, m in ipairs(meta) do + if m.type == 'task' and m.id == t.id then + task_lnum = lnum + break + end + end + assert.is_not_nil(task_lnum) + vim.api.nvim_win_set_cursor(0, { task_lnum, 0 }) + + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, level) + table.insert(messages, { msg = msg, level = level }) + end + + pending.goto_file() + + vim.notify = orig_notify + + local errored = false + for _, m in ipairs(messages) do + if m.level == vim.log.levels.ERROR and m.msg:find('File not found') then + errored = true + end + end + assert.is_true(errored) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + end) +end)