pending.nvim/spec/diff_spec.lua
Barrett Ruth 87679e9857 fix(diff): preserve due/rec when absent from buffer line
Problem: `diff.apply` overwrites `task.due` and `task.recur` with `nil`
whenever those fields aren't present as inline tokens in the buffer line.
Because metadata is rendered as virtual text (never in the line text),
every description edit silently clears due dates and recurrence rules.

Solution: Only update `due`, `recur`, and `recur_mode` in the existing-
task branch when the parsed entry actually contains them (non-nil). Users
can still set/change these inline by typing `due:<date>` or `rec:<rule>`;
clearing them requires `:Pending edit <id> -due`.
2026-03-05 12:18:57 -05:00

291 lines
7.9 KiB
Lua

require('spec.helpers')
local store = require('pending.store')
describe('diff', function()
local tmpdir
local s
local diff = require('pending.diff')
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
s = store.new(tmpdir .. '/tasks.json')
s:load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
end)
describe('parse_buffer', function()
it('parses headers and tasks', function()
local lines = {
'# School',
'/1/- [ ] Do homework',
'/2/- [!] Read chapter 5',
'',
'# Errands',
'/3/- [ ] Buy groceries',
}
local result = diff.parse_buffer(lines)
assert.are.equal(6, #result)
assert.are.equal('header', result[1].type)
assert.are.equal('School', result[1].category)
assert.are.equal('task', result[2].type)
assert.are.equal(1, result[2].id)
assert.are.equal('Do homework', result[2].description)
assert.are.equal('School', result[2].category)
assert.are.equal('task', result[3].type)
assert.are.equal(1, result[3].priority)
assert.are.equal('blank', result[4].type)
assert.are.equal('Errands', result[6].category)
end)
it('handles new tasks without ids', function()
local lines = {
'# Inbox',
'- [ ] New task here',
}
local result = diff.parse_buffer(lines)
assert.are.equal(2, #result)
assert.are.equal('task', result[2].type)
assert.is_nil(result[2].id)
assert.are.equal('New task here', result[2].description)
end)
it('inline cat: token overrides header category', function()
local lines = {
'# Inbox',
'/1/- [ ] Buy milk cat:Work',
}
local result = diff.parse_buffer(lines)
assert.are.equal(2, #result)
assert.are.equal('task', result[2].type)
assert.are.equal('Work', result[2].category)
end)
it('extracts rec: token from buffer line', function()
local lines = {
'# Inbox',
'/1/- [ ] Take trash out rec:weekly',
}
local result = diff.parse_buffer(lines)
assert.are.equal('weekly', result[2].rec)
end)
it('extracts rec: with completion mode', function()
local lines = {
'# Inbox',
'/1/- [ ] Water plants rec:!daily',
}
local result = diff.parse_buffer(lines)
assert.are.equal('daily', result[2].rec)
assert.are.equal('completion', result[2].rec_mode)
end)
it('inline due: token is parsed', function()
local lines = {
'# Inbox',
'/1/- [ ] Buy milk due:2026-03-15',
}
local result = diff.parse_buffer(lines)
assert.are.equal(2, #result)
assert.are.equal('task', result[2].type)
assert.are.equal('2026-03-15', result[2].due)
end)
end)
describe('apply', function()
it('creates new tasks from buffer lines', function()
local lines = {
'# Inbox',
'- [ ] First task',
'- [ ] Second task',
}
diff.apply(lines, s)
s:load()
local tasks = s:active_tasks()
assert.are.equal(2, #tasks)
assert.are.equal('First task', tasks[1].description)
assert.are.equal('Second task', tasks[2].description)
end)
it('deletes tasks removed from buffer', function()
s:add({ description = 'Keep me' })
s:add({ description = 'Delete me' })
s:save()
local lines = {
'# Inbox',
'/1/- [ ] Keep me',
}
diff.apply(lines, s)
s:load()
local active = s:active_tasks()
assert.are.equal(1, #active)
assert.are.equal('Keep me', active[1].description)
local deleted = s:get(2)
assert.are.equal('deleted', deleted.status)
end)
it('updates modified tasks', function()
s:add({ description = 'Original' })
s:save()
local lines = {
'# Inbox',
'/1/- [ ] Renamed',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('Renamed', task.description)
end)
it('updates modified when description is renamed', function()
local t = s:add({ description = 'Original', category = 'Inbox' })
t.modified = '2020-01-01T00:00:00Z'
s:save()
local lines = {
'# Inbox',
'/1/- [ ] Renamed',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('Renamed', task.description)
assert.is_not.equal('2020-01-01T00:00:00Z', task.modified)
end)
it('handles duplicate ids as copies', function()
s:add({ description = 'Original' })
s:save()
local lines = {
'# Inbox',
'/1/- [ ] Original',
'/1/- [ ] Copy of original',
}
diff.apply(lines, s)
s:load()
local tasks = s:active_tasks()
assert.are.equal(2, #tasks)
end)
it('moves tasks between categories', function()
s:add({ description = 'Moving task', category = 'Inbox' })
s:save()
local lines = {
'# Work',
'/1/- [ ] Moving task',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('Work', task.category)
end)
it('does not update modified when task is unchanged', function()
s:add({ description = 'Stable task', category = 'Inbox' })
s:save()
local lines = {
'# Inbox',
'/1/- [ ] Stable task',
}
diff.apply(lines, s)
s:load()
local modified_after_first = s:get(1).modified
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal(modified_after_first, task.modified)
end)
it('preserves due when not present in buffer line', function()
s:add({ description = 'Pay bill', due = '2026-03-15' })
s:save()
local lines = {
'# Inbox',
'/1/- [ ] Pay bill',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('2026-03-15', task.due)
end)
it('updates due when inline token is present', function()
s:add({ description = 'Pay bill', due = '2026-03-15' })
s:save()
local lines = {
'# Inbox',
'/1/- [ ] Pay bill due:2026-04-01',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('2026-04-01', task.due)
end)
it('stores recur field on new tasks from buffer', function()
local lines = {
'# Inbox',
'- [ ] Take out trash rec:weekly',
}
diff.apply(lines, s)
s:load()
local tasks = s:active_tasks()
assert.are.equal(1, #tasks)
assert.are.equal('weekly', tasks[1].recur)
end)
it('updates recur field when changed inline', function()
s:add({ description = 'Task', recur = 'daily' })
s:save()
local lines = {
'# Todo',
'/1/- [ ] Task rec:weekly',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('weekly', task.recur)
end)
it('preserves recur when not present in buffer line', function()
s:add({ description = 'Task', recur = 'daily' })
s:save()
local lines = {
'# Todo',
'/1/- [ ] Task',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal('daily', task.recur)
end)
it('parses rec: with completion mode prefix', function()
local lines = {
'# Inbox',
'- [ ] Water plants rec:!weekly',
}
diff.apply(lines, s)
s:load()
local tasks = s:active_tasks()
assert.are.equal('weekly', tasks[1].recur)
assert.are.equal('completion', tasks[1].recur_mode)
end)
it('clears priority when [N] is removed from buffer line', function()
s:add({ description = 'Task name', priority = 1 })
s:save()
local lines = {
'# Inbox',
'/1/- [ ] Task name',
}
diff.apply(lines, s)
s:load()
local task = s:get(1)
assert.are.equal(0, task.priority)
end)
end)
end)