From 8bd6bf8a6ab0aa94474e978f379baf37ca29bb70 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:10:09 -0500 Subject: [PATCH] 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. --- spec/diff_spec.lua | 139 ++++++++++++++++++++++++++++++++ spec/helpers.lua | 38 +++++++++ spec/parse_spec.lua | 117 +++++++++++++++++++++++++++ spec/store_spec.lua | 189 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 483 insertions(+) create mode 100644 spec/diff_spec.lua create mode 100644 spec/helpers.lua create mode 100644 spec/parse_spec.lua create mode 100644 spec/store_spec.lua diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua new file mode 100644 index 0000000..f1b7cf8 --- /dev/null +++ b/spec/diff_spec.lua @@ -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) diff --git a/spec/helpers.lua b/spec/helpers.lua new file mode 100644 index 0000000..2639c89 --- /dev/null +++ b/spec/helpers.lua @@ -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 diff --git a/spec/parse_spec.lua b/spec/parse_spec.lua new file mode 100644 index 0000000..a76cf31 --- /dev/null +++ b/spec/parse_spec.lua @@ -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) diff --git a/spec/store_spec.lua b/spec/store_spec.lua new file mode 100644 index 0000000..588a3ec --- /dev/null +++ b/spec/store_spec.lua @@ -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)