require('spec.helpers') local config = require('pending.config') local diff = require('pending.diff') local parse = require('pending.parse') local store = require('pending.store') local views = require('pending.views') describe('file token', function() local tmpdir local s before_each(function() tmpdir = vim.fn.tempname() vim.fn.mkdir(tmpdir, 'p') vim.g.pending = { data_path = tmpdir .. '/tasks.json' } config.reset() package.loaded['pending'] = nil package.loaded['pending.buffer'] = nil s = store.new(tmpdir .. '/tasks.json') s:load() end) after_each(function() vim.fn.delete(tmpdir, 'rf') vim.g.pending = nil config.reset() package.loaded['pending'] = nil package.loaded['pending.buffer'] = nil 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 = s:add({ description = 'Task one' }) s:save() local lines = { '/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42', } diff.apply(lines, s) local updated = s: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 = s:add({ description = 'Task one', _extra = { file = 'old.lua:1' } }) s:save() local lines = { '/' .. t.id .. '/- [ ] Task one file:new.lua:99', } diff.apply(lines, s) local updated = s: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 = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } }) s:save() local lines = { '/' .. t.id .. '/- [ ] Task one', } diff.apply(lines, s) local updated = s:get(t.id) assert.is_nil(updated._extra) end) it('preserves other _extra fields when file is cleared', function() local t = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc123' }, }) s:save() local lines = { '/' .. t.id .. '/- [ ] Task one', } diff.apply(lines, s) local updated = s: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 = s:add({ description = 'Task one' }) s:save() local lines = { '/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42', } diff.apply(lines, s) s:load() local loaded = s: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 = s:add({ description = 'Task one' }) s:save() local lines = { '/' .. t.id .. '/- [ ] Task one', } assert.has_no_error(function() diff.apply(lines, s, {}) end) end) end) describe('LineMeta', function() it('category_view populates file field in LineMeta', function() local t = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' }, }) s:save() local tasks = s: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 = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' }, }) s:save() local tasks = s: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 = s:add({ description = 'Task one' }) s:save() local tasks = s: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 = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } }) s:save() pending.edit(tostring(t.id), '-file') s:load() local updated = s:get(t.id) assert.is_nil(updated._extra) end) it('shows feedback when file reference is removed', function() local pending = require('pending') local t = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } }) s: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 = s:add({ description = 'Task one' }) s: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 = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc' }, }) s:save() pending.edit(tostring(t.id), '-file') s:load() local updated = s: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 = s:add({ description = 'Task one' }) s:save() buffer.set_store(s) 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 = s:add({ description = 'Task one', _extra = { file = 'nonexistent/path.lua:1' }, }) s:save() buffer.set_store(s) 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)