refactor: organize tests and dry (#49)

* refactor(store): convert singleton to Store.new() factory

Problem: store.lua used module-level _data singleton, making
project-local stores impossible and creating hidden global state.

Solution: introduce Store metatable with all operations as instance
methods. M.new(path) constructs an instance; M.resolve_path()
searches upward for .pending.json and falls back to
config.get().data_path. Singleton module API is removed.

* refactor(diff): accept store instance as parameter

Problem: diff.apply called store singleton methods directly, coupling
it to global state and preventing use with project-local stores.

Solution: change signature to apply(lines, s, hidden_ids?) where s is
a pending.Store instance. All store operations now go through s.

* refactor(buffer): add set_store/store accessors, drop singleton dep

Problem: buffer.lua imported store directly and called singleton
methods, preventing it from working with per-project store instances.

Solution: add module-level _store, M.set_store(s), and M.store()
accessors. open() and render() use _store instead of the singleton.
init.lua will call buffer.set_store(s) before buffer.open().

* refactor(complete,health,sync,plugin): update callers to store instance API

Problem: complete.lua, health.lua, sync/gcal.lua, and plugin/pending.lua
all called singleton store methods directly.

Solution: complete.lua uses buffer.store() for category lookups;
health.lua uses store.new(store.resolve_path()) and reports the
resolved path; gcal.lua calls require('pending').store() for task
access; plugin tab-completion creates ephemeral store instances via
store.new(store.resolve_path()). Add 'init' to the subcommands list.

* feat(init): thread Store instance through init, add :Pending init

Problem: init.lua called singleton store methods throughout, and there
was no way to create a project-local .pending.json file.

Solution: add module-level _store and private get_store() that
lazy-constructs via store.new(store.resolve_path()). Add public
M.store() accessor used by specs and sync backends. M.open() calls
buffer.set_store(get_store()) before buffer.open(). All store
callsites converted to get_store():method(). goto_file() and
add_here() derive the data directory from get_store().path.

Add M.init() which creates .pending.json in cwd and dispatches from
M.command() as ':Pending init'.

* test: update all specs for Store instance API

Problem: every spec used the old singleton API (store.unload(),
store.load(), store.add(), etc.) and diff.apply(lines, hidden).

Solution: lower-level specs (store, diff, views, complete, file) use
s = store.new(path); s:load() directly. Higher-level specs (archive,
edit, filter, status, sync) reset package.loaded['pending'] in
before_each and use pending.store() to access the live instance.
diff.apply calls updated to diff.apply(lines, s, hidden_ids).

* docs(pending): document :Pending init and store resolution

Add *pending-store-resolution* section explaining upward .pending.json
discovery and fallback to the global data_path. Document :Pending init
under COMMANDS. Add a cross-reference from the data_path config field.

* ci: format

* ci: remove unused variable
This commit is contained in:
Barrett Ruth 2026-02-26 20:03:42 -05:00
parent dbd76d6759
commit 41bda24570
19 changed files with 819 additions and 703 deletions

View file

@ -1,32 +1,34 @@
require('spec.helpers')
local config = require('pending.config')
local store = require('pending.store')
describe('edit', function()
local tmpdir
local pending = require('pending')
local pending
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
store.unload()
store.load()
package.loaded['pending'] = nil
pending = require('pending')
pending.store():load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
package.loaded['pending'] = nil
end)
it('sets due date with resolve_date vocabulary', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'due:tomorrow')
local updated = store.get(t.id)
local updated = s:get(t.id)
local today = os.date('*t') --[[@as osdate]]
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }))
@ -34,111 +36,123 @@ describe('edit', function()
end)
it('sets due date with literal YYYY-MM-DD', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'due:2026-06-15')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.are.equal('2026-06-15', updated.due)
end)
it('sets category', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'cat:Work')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.are.equal('Work', updated.category)
end)
it('adds priority', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), '+!')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.are.equal(1, updated.priority)
end)
it('removes priority', function()
local t = store.add({ description = 'Task one', priority = 1 })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one', priority = 1 })
s:save()
pending.edit(tostring(t.id), '-!')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.are.equal(0, updated.priority)
end)
it('removes due date', function()
local t = store.add({ description = 'Task one', due = '2026-06-15' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one', due = '2026-06-15' })
s:save()
pending.edit(tostring(t.id), '-due')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.is_nil(updated.due)
end)
it('removes category', function()
local t = store.add({ description = 'Task one', category = 'Work' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one', category = 'Work' })
s:save()
pending.edit(tostring(t.id), '-cat')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.is_nil(updated.category)
end)
it('sets recurrence', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'rec:weekly')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.are.equal('weekly', updated.recur)
assert.is_nil(updated.recur_mode)
end)
it('sets completion-based recurrence', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'rec:!daily')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.are.equal('daily', updated.recur)
assert.are.equal('completion', updated.recur_mode)
end)
it('removes recurrence', function()
local t = store.add({ description = 'Task one', recur = 'weekly', recur_mode = 'scheduled' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one', recur = 'weekly', recur_mode = 'scheduled' })
s:save()
pending.edit(tostring(t.id), '-rec')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.is_nil(updated.recur)
assert.is_nil(updated.recur_mode)
end)
it('applies multiple operations at once', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'due:today cat:Errands +!')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.are.equal(os.date('%Y-%m-%d'), updated.due)
assert.are.equal('Errands', updated.category)
assert.are.equal(1, updated.priority)
end)
it('pushes to undo stack', function()
local t = store.add({ description = 'Task one' })
store.save()
local stack_before = #store.undo_stack()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
local stack_before = #s:undo_stack()
pending.edit(tostring(t.id), 'cat:Work')
assert.are.equal(stack_before + 1, #store.undo_stack())
assert.are.equal(stack_before + 1, #s:undo_stack())
end)
it('persists changes to disk', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'cat:Work')
store.unload()
store.load()
local updated = store.get(t.id)
s:load()
local updated = s:get(t.id)
assert.are.equal('Work', updated.category)
end)
it('errors on unknown task ID', function()
store.add({ description = 'Task one' })
store.save()
local s = pending.store()
s:add({ description = 'Task one' })
s:save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
@ -152,8 +166,9 @@ describe('edit', function()
end)
it('errors on invalid date', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
@ -167,8 +182,9 @@ describe('edit', function()
end)
it('errors on unknown operation token', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
@ -182,8 +198,9 @@ describe('edit', function()
end)
it('errors on invalid recurrence pattern', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
@ -197,8 +214,9 @@ describe('edit', function()
end)
it('errors when no operations given', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
@ -238,8 +256,9 @@ describe('edit', function()
end)
it('shows feedback message on success', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
@ -255,12 +274,14 @@ describe('edit', function()
it('respects custom date_syntax', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', date_syntax = 'by' }
config.reset()
store.unload()
store.load()
local t = store.add({ description = 'Task one' })
store.save()
package.loaded['pending'] = nil
pending = require('pending')
local s = pending.store()
s:load()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'by:tomorrow')
local updated = store.get(t.id)
local updated = s:get(t.id)
local today = os.date('*t') --[[@as osdate]]
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }))
@ -270,32 +291,36 @@ describe('edit', function()
it('respects custom recur_syntax', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', recur_syntax = 'repeat' }
config.reset()
store.unload()
store.load()
local t = store.add({ description = 'Task one' })
store.save()
package.loaded['pending'] = nil
pending = require('pending')
local s = pending.store()
s:load()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'repeat:weekly')
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.are.equal('weekly', updated.recur)
end)
it('does not modify store on error', function()
local t = store.add({ description = 'Task one', category = 'Original' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one', category = 'Original' })
s:save()
local orig_notify = vim.notify
vim.notify = function() end
pending.edit(tostring(t.id), 'due:notadate')
vim.notify = orig_notify
local updated = store.get(t.id)
local updated = s:get(t.id)
assert.are.equal('Original', updated.category)
assert.is_nil(updated.due)
end)
it('sets due date with datetime format', function()
local t = store.add({ description = 'Task one' })
store.save()
local s = pending.store()
local t = s:add({ description = 'Task one' })
s:save()
pending.edit(tostring(t.id), 'due:tomorrow@14:00')
local updated = store.get(t.id)
local updated = s:get(t.id)
local today = os.date('*t') --[[@as osdate]]
local expected =
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 }))