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:
parent
126724b939
commit
5055e4a36b
4 changed files with 483 additions and 0 deletions
139
spec/diff_spec.lua
Normal file
139
spec/diff_spec.lua
Normal 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
38
spec/helpers.lua
Normal 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
117
spec/parse_spec.lua
Normal 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
189
spec/store_spec.lua
Normal 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)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue