* refactor(config): change default category from Inbox to Todo * refactor(views): adopt markdown checkbox line format Problem: task lines used an opaque /ID/ [N] prefix format that was hard to read and inconsistent between category and priority views. Header lines had no visual marker distinguishing them from tasks. Solution: render headers as '## Cat', task lines as '/ID/- [x|!| ] description'. State encoding: [x]=done, [!]=urgent, [ ]=pending. Both views use the same construction. * refactor(diff): parse and reconcile markdown checkbox format Problem: parse_buffer matched the old ' text' indent pattern and detected headers via '^%S'. Priority was read from a '[N] ' prefix. apply() never reconciled status changes written into the buffer. Solution: match '- [.] text' for tasks and '^## ' for headers. Extract state char to derive priority (! -> 1) and status (x -> done). apply() now reconciles status from the buffer, setting/clearing 'end' timestamps — enabling the oil-style edit-checkbox-then-:w workflow. * refactor(buffer): update syntax, extmarks, and render for checkbox format Problem: syntax patterns matched the old indent/[N] format; right_align virtual text produced a broken layout in narrow windows; the done strikethrough skipped past the ' ' indent leaving '- [x] ' unstyled; render() added undo history entries so 'u' could undo a re-render. Solution: update taskHeader/taskLine patterns for '## '/'- [.]'; rename taskPriority -> taskCheckbox matching '[!]'; switch virt_text_pos to 'eol'; drop the +2 col_start offset so strikethrough covers '- [x] '; guard nvim_buf_set_lines with undolevels=-1 so renders are not undoable. Also fix open_line to insert '- [ ] ' and position cursor at col 6. * refactor(init): replace multi-level priority with binary toggle Problem: <C-a>/<C-x> overrode Vim's native number increment and the visual g<C-a>/g<C-x> variants added complexity for marginal value. toggle_complete() left the cursor on the wrong line after re-render. Solution: remove change_priority/change_priority_visual; add toggle_priority() (0<->1) mapped to '!', with cursor-follow after render matching the pattern already used in priority toggle. Add cursor-follow to toggle_complete() for the same reason. Update plugin plugs (priority-up/down -> priority) and add 'due'/'undo' to the :Pending completion list. Update help text accordingly. * feat(buffer): reflect current view in buffer name Problem: no way to tell at a glance which view (category vs priority) is active — the buffer was always named 'pending://'. Solution: update the buffer name to 'pending://category' or 'pending://priority' on every render, so the view is visible in the statusline/tabline without any extra UI.
237 lines
6.9 KiB
Lua
237 lines
6.9 KiB
Lua
require('spec.helpers')
|
|
|
|
local config = require('pending.config')
|
|
local store = require('pending.store')
|
|
|
|
describe('store', 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()
|
|
end)
|
|
|
|
after_each(function()
|
|
vim.fn.delete(tmpdir, 'rf')
|
|
vim.g.pending = nil
|
|
config.reset()
|
|
end)
|
|
|
|
describe('load', function()
|
|
it('returns empty data when no file exists', function()
|
|
local data = store.load()
|
|
assert.are.equal(1, data.version)
|
|
assert.are.equal(1, data.next_id)
|
|
assert.are.same({}, data.tasks)
|
|
end)
|
|
|
|
it('loads existing data', function()
|
|
local path = config.get().data_path
|
|
local f = io.open(path, 'w')
|
|
f:write(vim.json.encode({
|
|
version = 1,
|
|
next_id = 3,
|
|
tasks = {
|
|
{
|
|
id = 1,
|
|
description = 'Pending one',
|
|
status = 'pending',
|
|
entry = '2026-01-01T00:00:00Z',
|
|
modified = '2026-01-01T00:00:00Z',
|
|
},
|
|
{
|
|
id = 2,
|
|
description = 'Pending two',
|
|
status = 'done',
|
|
entry = '2026-01-01T00:00:00Z',
|
|
modified = '2026-01-01T00:00:00Z',
|
|
},
|
|
},
|
|
}))
|
|
f:close()
|
|
local data = store.load()
|
|
assert.are.equal(3, data.next_id)
|
|
assert.are.equal(2, #data.tasks)
|
|
assert.are.equal('Pending one', data.tasks[1].description)
|
|
assert.are.equal('done', data.tasks[2].status)
|
|
end)
|
|
|
|
it('preserves unknown fields', function()
|
|
local path = config.get().data_path
|
|
local f = io.open(path, 'w')
|
|
f:write(vim.json.encode({
|
|
version = 1,
|
|
next_id = 2,
|
|
tasks = {
|
|
{
|
|
id = 1,
|
|
description = 'Pending',
|
|
status = 'pending',
|
|
entry = '2026-01-01T00:00:00Z',
|
|
modified = '2026-01-01T00:00:00Z',
|
|
custom_field = 'hello',
|
|
},
|
|
},
|
|
}))
|
|
f:close()
|
|
store.load()
|
|
local task = store.get(1)
|
|
assert.is_not_nil(task._extra)
|
|
assert.are.equal('hello', task._extra.custom_field)
|
|
end)
|
|
end)
|
|
|
|
describe('add', function()
|
|
it('creates a task with incremented id', function()
|
|
store.load()
|
|
local t1 = store.add({ description = 'First' })
|
|
local t2 = store.add({ description = 'Second' })
|
|
assert.are.equal(1, t1.id)
|
|
assert.are.equal(2, t2.id)
|
|
assert.are.equal('pending', t1.status)
|
|
assert.are.equal('Todo', t1.category)
|
|
end)
|
|
|
|
it('uses provided category', function()
|
|
store.load()
|
|
local t = store.add({ description = 'Test', category = 'Work' })
|
|
assert.are.equal('Work', t.category)
|
|
end)
|
|
end)
|
|
|
|
describe('update', function()
|
|
it('updates fields and sets modified', function()
|
|
store.load()
|
|
local t = store.add({ description = 'Original' })
|
|
t.modified = '2025-01-01T00:00:00Z'
|
|
store.update(t.id, { description = 'Updated' })
|
|
local updated = store.get(t.id)
|
|
assert.are.equal('Updated', updated.description)
|
|
assert.is_not.equal('2025-01-01T00:00:00Z', updated.modified)
|
|
end)
|
|
|
|
it('sets end timestamp on completion', function()
|
|
store.load()
|
|
local t = store.add({ description = 'Test' })
|
|
assert.is_nil(t['end'])
|
|
store.update(t.id, { status = 'done' })
|
|
local updated = store.get(t.id)
|
|
assert.is_not_nil(updated['end'])
|
|
end)
|
|
|
|
it('does not overwrite id or entry', function()
|
|
store.load()
|
|
local t = store.add({ description = 'Immutable fields' })
|
|
local original_id = t.id
|
|
local original_entry = t.entry
|
|
store.update(t.id, { id = 999, entry = 'x' })
|
|
local updated = store.get(original_id)
|
|
assert.are.equal(original_id, updated.id)
|
|
assert.are.equal(original_entry, updated.entry)
|
|
end)
|
|
|
|
it('does not overwrite end on second completion', function()
|
|
store.load()
|
|
local t = store.add({ description = 'Complete twice' })
|
|
store.update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' })
|
|
local first_end = store.get(t.id)['end']
|
|
store.update(t.id, { status = 'done' })
|
|
local task = store.get(t.id)
|
|
assert.are.equal(first_end, task['end'])
|
|
end)
|
|
end)
|
|
|
|
describe('delete', function()
|
|
it('marks task as deleted', function()
|
|
store.load()
|
|
local t = store.add({ description = 'To delete' })
|
|
store.delete(t.id)
|
|
local deleted = store.get(t.id)
|
|
assert.are.equal('deleted', deleted.status)
|
|
assert.is_not_nil(deleted['end'])
|
|
end)
|
|
end)
|
|
|
|
describe('save and round-trip', function()
|
|
it('persists and reloads correctly', function()
|
|
store.load()
|
|
store.add({ description = 'Persisted', category = 'Work', priority = 1 })
|
|
store.save()
|
|
store.unload()
|
|
store.load()
|
|
local tasks = store.active_tasks()
|
|
assert.are.equal(1, #tasks)
|
|
assert.are.equal('Persisted', tasks[1].description)
|
|
assert.are.equal('Work', tasks[1].category)
|
|
assert.are.equal(1, tasks[1].priority)
|
|
end)
|
|
|
|
it('round-trips unknown fields', function()
|
|
local path = config.get().data_path
|
|
local f = io.open(path, 'w')
|
|
f:write(vim.json.encode({
|
|
version = 1,
|
|
next_id = 2,
|
|
tasks = {
|
|
{
|
|
id = 1,
|
|
description = 'Pending',
|
|
status = 'pending',
|
|
entry = '2026-01-01T00:00:00Z',
|
|
modified = '2026-01-01T00:00:00Z',
|
|
_gcal_event_id = 'abc123',
|
|
},
|
|
},
|
|
}))
|
|
f:close()
|
|
store.load()
|
|
store.save()
|
|
store.unload()
|
|
store.load()
|
|
local task = store.get(1)
|
|
assert.are.equal('abc123', task._extra._gcal_event_id)
|
|
end)
|
|
end)
|
|
|
|
describe('active_tasks', function()
|
|
it('excludes deleted tasks', function()
|
|
store.load()
|
|
store.add({ description = 'Active' })
|
|
local t2 = store.add({ description = 'To delete' })
|
|
store.delete(t2.id)
|
|
local active = store.active_tasks()
|
|
assert.are.equal(1, #active)
|
|
assert.are.equal('Active', active[1].description)
|
|
end)
|
|
end)
|
|
|
|
describe('snapshot', function()
|
|
it('returns a table of tasks', function()
|
|
store.load()
|
|
store.add({ description = 'Snap one' })
|
|
store.add({ description = 'Snap two' })
|
|
local snap = store.snapshot()
|
|
assert.are.equal(2, #snap)
|
|
end)
|
|
|
|
it('returns a copy that does not affect the store', function()
|
|
store.load()
|
|
local t = store.add({ description = 'Original' })
|
|
local snap = store.snapshot()
|
|
snap[1].description = 'Mutated'
|
|
local live = store.get(t.id)
|
|
assert.are.equal('Original', live.description)
|
|
end)
|
|
|
|
it('excludes deleted tasks', function()
|
|
store.load()
|
|
local t = store.add({ description = 'Will be deleted' })
|
|
store.delete(t.id)
|
|
local snap = store.snapshot()
|
|
assert.are.equal(0, #snap)
|
|
end)
|
|
end)
|
|
end)
|