test: add store, parse, and diff specs

Problem: need test coverage for core data operations, inline
metadata parsing, and buffer diff algorithm.

Solution: add busted specs for store CRUD, round-trip
preservation, parse body/command_add with configurable date
syntax, and diff create/delete/update/copy/move operations.
This commit is contained in:
Barrett Ruth 2026-02-24 15:10:09 -05:00 committed by Barrett Ruth
parent dfe09ef721
commit 8bd6bf8a6a
4 changed files with 483 additions and 0 deletions

139
spec/diff_spec.lua Normal file
View file

@ -0,0 +1,139 @@
require('spec.helpers')
local config = require('todo.config')
local store = require('todo.store')
describe('diff', function()
local tmpdir
local diff = require('todo.diff')
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.todo = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
store.load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.todo = nil
config.reset()
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)
end)
describe('apply', function()
it('creates new tasks from buffer lines', function()
local lines = {
'Inbox',
' First task',
' Second task',
}
diff.apply(lines)
store.unload()
store.load()
local tasks = store.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()
store.add({ description = 'Keep me' })
store.add({ description = 'Delete me' })
store.save()
local lines = {
'Inbox',
'/1/ Keep me',
}
diff.apply(lines)
store.unload()
store.load()
local active = store.active_tasks()
assert.are.equal(1, #active)
assert.are.equal('Keep me', active[1].description)
local deleted = store.get(2)
assert.are.equal('deleted', deleted.status)
end)
it('updates modified tasks', function()
store.add({ description = 'Original' })
store.save()
local lines = {
'Inbox',
'/1/ Renamed',
}
diff.apply(lines)
store.unload()
store.load()
local task = store.get(1)
assert.are.equal('Renamed', task.description)
end)
it('handles duplicate ids as copies', function()
store.add({ description = 'Original' })
store.save()
local lines = {
'Inbox',
'/1/ Original',
'/1/ Copy of original',
}
diff.apply(lines)
store.unload()
store.load()
local tasks = store.active_tasks()
assert.are.equal(2, #tasks)
end)
it('moves tasks between categories', function()
store.add({ description = 'Moving task', category = 'Inbox' })
store.save()
local lines = {
'Work',
'/1/ Moving task',
}
diff.apply(lines)
store.unload()
store.load()
local task = store.get(1)
assert.are.equal('Work', task.category)
end)
end)
end)

38
spec/helpers.lua Normal file
View file

@ -0,0 +1,38 @@
local plugin_dir = vim.fn.getcwd()
vim.opt.runtimepath:prepend(plugin_dir)
vim.opt.packpath = {}
vim.cmd('filetype on')
local M = {}
---@param lines? string[]
---@return integer
function M.create_buffer(lines)
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {})
return bufnr
end
---@param bufnr integer
function M.delete_buffer(bufnr)
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
vim.api.nvim_buf_delete(bufnr, { force = true })
end
end
---@param bufnr integer
---@param ns integer
---@return table[]
function M.get_extmarks(bufnr, ns)
return vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { details = true })
end
---@return string
function M.tmpdir()
local dir = vim.fn.tempname()
vim.fn.mkdir(dir, 'p')
return dir
end
return M

117
spec/parse_spec.lua Normal file
View file

@ -0,0 +1,117 @@
require('spec.helpers')
local config = require('todo.config')
describe('parse', function()
before_each(function()
vim.g.todo = nil
config.reset()
end)
after_each(function()
vim.g.todo = nil
config.reset()
end)
local parse = require('todo.parse')
describe('body', function()
it('returns plain description when no metadata', function()
local desc, meta = parse.body('Buy groceries')
assert.are.equal('Buy groceries', desc)
assert.are.same({}, meta)
end)
it('extracts due date', function()
local desc, meta = parse.body('Buy groceries due:2026-03-15')
assert.are.equal('Buy groceries', desc)
assert.are.equal('2026-03-15', meta.due)
end)
it('extracts category', function()
local desc, meta = parse.body('Buy groceries cat:Errands')
assert.are.equal('Buy groceries', desc)
assert.are.equal('Errands', meta.cat)
end)
it('extracts both due and cat', function()
local desc, meta = parse.body('Buy milk due:2026-03-15 cat:Errands')
assert.are.equal('Buy milk', desc)
assert.are.equal('2026-03-15', meta.due)
assert.are.equal('Errands', meta.cat)
end)
it('extracts metadata in any order', function()
local desc, meta = parse.body('Buy milk cat:Errands due:2026-03-15')
assert.are.equal('Buy milk', desc)
assert.are.equal('2026-03-15', meta.due)
assert.are.equal('Errands', meta.cat)
end)
it('stops at duplicate key', function()
local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01')
assert.are.equal('Buy milk due:2026-03-15', desc)
assert.are.equal('2026-04-01', meta.due)
end)
it('stops at non-meta token', function()
local desc, meta = parse.body('Buy milk for breakfast due:2026-03-15')
assert.are.equal('Buy milk for breakfast', desc)
assert.are.equal('2026-03-15', meta.due)
end)
it('rejects invalid dates', function()
local desc, meta = parse.body('Buy milk due:2026-13-15')
assert.are.equal('Buy milk due:2026-13-15', desc)
assert.is_nil(meta.due)
end)
it('preserves colons in description', function()
local desc, meta = parse.body('Meeting at 3:00pm')
assert.are.equal('Meeting at 3:00pm', desc)
assert.are.same({}, meta)
end)
it('uses configurable date syntax', function()
vim.g.todo = { date_syntax = 'by' }
config.reset()
local desc, meta = parse.body('Buy milk by:2026-03-15')
assert.are.equal('Buy milk', desc)
assert.are.equal('2026-03-15', meta.due)
end)
it('ignores old syntax when date_syntax is changed', function()
vim.g.todo = { date_syntax = 'by' }
config.reset()
local desc, meta = parse.body('Buy milk due:2026-03-15')
assert.are.equal('Buy milk due:2026-03-15', desc)
assert.is_nil(meta.due)
end)
end)
describe('command_add', function()
it('parses simple text', function()
local desc, meta = parse.command_add('Buy milk')
assert.are.equal('Buy milk', desc)
assert.are.same({}, meta)
end)
it('detects category prefix', function()
local desc, meta = parse.command_add('School: Do homework')
assert.are.equal('Do homework', desc)
assert.are.equal('School', meta.cat)
end)
it('ignores lowercase prefix', function()
local desc, meta = parse.command_add('hello: world')
assert.are.equal('hello: world', desc)
end)
it('combines category prefix with inline metadata', function()
local desc, meta = parse.command_add('School: Do homework due:2026-03-15')
assert.are.equal('Do homework', desc)
assert.are.equal('School', meta.cat)
assert.are.equal('2026-03-15', meta.due)
end)
end)
end)

189
spec/store_spec.lua Normal file
View file

@ -0,0 +1,189 @@
require('spec.helpers')
local config = require('todo.config')
local store = require('todo.store')
describe('store', function()
local tmpdir
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.todo = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.todo = 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 = 'Todo one',
status = 'pending',
entry = '2026-01-01T00:00:00Z',
modified = '2026-01-01T00:00:00Z',
},
{
id = 2,
description = 'Todo 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('Todo 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 = 'Todo',
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('Inbox', 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' })
local original_modified = t.modified
store.update(t.id, { description = 'Updated' })
local updated = store.get(t.id)
assert.are.equal('Updated', updated.description)
assert.is_not.equal(original_modified, 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)
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 = 'Todo',
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)
end)