* 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
358 lines
11 KiB
Lua
358 lines
11 KiB
Lua
require('spec.helpers')
|
|
|
|
local config = require('pending.config')
|
|
local diff = require('pending.diff')
|
|
local parse = require('pending.parse')
|
|
local store = require('pending.store')
|
|
local views = require('pending.views')
|
|
|
|
describe('file token', function()
|
|
local tmpdir
|
|
local s
|
|
|
|
before_each(function()
|
|
tmpdir = vim.fn.tempname()
|
|
vim.fn.mkdir(tmpdir, 'p')
|
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
|
config.reset()
|
|
package.loaded['pending'] = nil
|
|
package.loaded['pending.buffer'] = nil
|
|
s = store.new(tmpdir .. '/tasks.json')
|
|
s:load()
|
|
end)
|
|
|
|
after_each(function()
|
|
vim.fn.delete(tmpdir, 'rf')
|
|
vim.g.pending = nil
|
|
config.reset()
|
|
package.loaded['pending'] = nil
|
|
package.loaded['pending.buffer'] = nil
|
|
end)
|
|
|
|
describe('parse.body', function()
|
|
it('extracts file token with path and line number', function()
|
|
local desc, meta = parse.body('Fix the bug file:src/auth.lua:42')
|
|
assert.are.equal('Fix the bug', desc)
|
|
assert.are.equal('src/auth.lua:42', meta.file)
|
|
end)
|
|
|
|
it('extracts file token with nested path', function()
|
|
local desc, meta = parse.body('Do something file:lua/pending/init.lua:100')
|
|
assert.are.equal('Do something', desc)
|
|
assert.are.equal('lua/pending/init.lua:100', meta.file)
|
|
end)
|
|
|
|
it('strips file token from description', function()
|
|
local desc, meta = parse.body('Task description file:foo.lua:1')
|
|
assert.are.equal('Task description', desc)
|
|
assert.are.equal('foo.lua:1', meta.file)
|
|
end)
|
|
|
|
it('stops parsing on duplicate file token', function()
|
|
local desc, meta = parse.body('Task file:b.lua:2 file:a.lua:1')
|
|
assert.are.equal('Task file:b.lua:2', desc)
|
|
assert.are.equal('a.lua:1', meta.file)
|
|
end)
|
|
|
|
it('treats malformed file token (no line number) as non-metadata', function()
|
|
local desc, meta = parse.body('Task file:nolineno')
|
|
assert.are.equal('Task file:nolineno', desc)
|
|
assert.is_nil(meta.file)
|
|
end)
|
|
|
|
it('treats file: prefix with no path as non-metadata', function()
|
|
local desc, meta = parse.body('Task file:')
|
|
assert.are.equal('Task file:', desc)
|
|
assert.is_nil(meta.file)
|
|
end)
|
|
|
|
it('handles file token alongside other metadata tokens', function()
|
|
local desc, meta = parse.body('Task cat:Work file:src/main.lua:10')
|
|
assert.are.equal('Task', desc)
|
|
assert.are.equal('Work', meta.cat)
|
|
assert.are.equal('src/main.lua:10', meta.file)
|
|
end)
|
|
|
|
it('does not extract file token when line number is not numeric', function()
|
|
local desc, meta = parse.body('Task file:src/foo.lua:abc')
|
|
assert.are.equal('Task file:src/foo.lua:abc', desc)
|
|
assert.is_nil(meta.file)
|
|
end)
|
|
end)
|
|
|
|
describe('diff reconciliation', function()
|
|
it('stores file field in _extra on write', function()
|
|
local t = s:add({ description = 'Task one' })
|
|
s:save()
|
|
local lines = {
|
|
'/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42',
|
|
}
|
|
diff.apply(lines, s)
|
|
local updated = s:get(t.id)
|
|
assert.is_not_nil(updated._extra)
|
|
assert.are.equal('src/auth.lua:42', updated._extra.file)
|
|
end)
|
|
|
|
it('updates file field when token changes', function()
|
|
local t = s:add({ description = 'Task one', _extra = { file = 'old.lua:1' } })
|
|
s:save()
|
|
local lines = {
|
|
'/' .. t.id .. '/- [ ] Task one file:new.lua:99',
|
|
}
|
|
diff.apply(lines, s)
|
|
local updated = s:get(t.id)
|
|
assert.are.equal('new.lua:99', updated._extra.file)
|
|
end)
|
|
|
|
it('clears file field when token is removed from line', function()
|
|
local t = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } })
|
|
s:save()
|
|
local lines = {
|
|
'/' .. t.id .. '/- [ ] Task one',
|
|
}
|
|
diff.apply(lines, s)
|
|
local updated = s:get(t.id)
|
|
assert.is_nil(updated._extra)
|
|
end)
|
|
|
|
it('preserves other _extra fields when file is cleared', function()
|
|
local t = s:add({
|
|
description = 'Task one',
|
|
_extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc123' },
|
|
})
|
|
s:save()
|
|
local lines = {
|
|
'/' .. t.id .. '/- [ ] Task one',
|
|
}
|
|
diff.apply(lines, s)
|
|
local updated = s:get(t.id)
|
|
assert.is_not_nil(updated._extra)
|
|
assert.is_nil(updated._extra.file)
|
|
assert.are.equal('abc123', updated._extra._gcal_event_id)
|
|
end)
|
|
|
|
it('round-trips file field through JSON', function()
|
|
local t = s:add({ description = 'Task one' })
|
|
s:save()
|
|
local lines = {
|
|
'/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42',
|
|
}
|
|
diff.apply(lines, s)
|
|
s:load()
|
|
local loaded = s:get(t.id)
|
|
assert.is_not_nil(loaded._extra)
|
|
assert.are.equal('src/auth.lua:42', loaded._extra.file)
|
|
end)
|
|
|
|
it('accepts optional hidden_ids parameter without error', function()
|
|
local t = s:add({ description = 'Task one' })
|
|
s:save()
|
|
local lines = {
|
|
'/' .. t.id .. '/- [ ] Task one',
|
|
}
|
|
assert.has_no_error(function()
|
|
diff.apply(lines, s, {})
|
|
end)
|
|
end)
|
|
end)
|
|
|
|
describe('LineMeta', function()
|
|
it('category_view populates file field in LineMeta', function()
|
|
local t = s:add({
|
|
description = 'Task one',
|
|
_extra = { file = 'src/auth.lua:42' },
|
|
})
|
|
s:save()
|
|
local tasks = s:active_tasks()
|
|
local _, meta = views.category_view(tasks)
|
|
local task_meta = nil
|
|
for _, m in ipairs(meta) do
|
|
if m.type == 'task' and m.id == t.id then
|
|
task_meta = m
|
|
break
|
|
end
|
|
end
|
|
assert.is_not_nil(task_meta)
|
|
assert.are.equal('src/auth.lua:42', task_meta.file)
|
|
end)
|
|
|
|
it('priority_view populates file field in LineMeta', function()
|
|
local t = s:add({
|
|
description = 'Task one',
|
|
_extra = { file = 'src/auth.lua:42' },
|
|
})
|
|
s:save()
|
|
local tasks = s:active_tasks()
|
|
local _, meta = views.priority_view(tasks)
|
|
local task_meta = nil
|
|
for _, m in ipairs(meta) do
|
|
if m.type == 'task' and m.id == t.id then
|
|
task_meta = m
|
|
break
|
|
end
|
|
end
|
|
assert.is_not_nil(task_meta)
|
|
assert.are.equal('src/auth.lua:42', task_meta.file)
|
|
end)
|
|
|
|
it('file field is nil in LineMeta when task has no file', function()
|
|
local t = s:add({ description = 'Task one' })
|
|
s:save()
|
|
local tasks = s:active_tasks()
|
|
local _, meta = views.category_view(tasks)
|
|
local task_meta = nil
|
|
for _, m in ipairs(meta) do
|
|
if m.type == 'task' and m.id == t.id then
|
|
task_meta = m
|
|
break
|
|
end
|
|
end
|
|
assert.is_not_nil(task_meta)
|
|
assert.is_nil(task_meta.file)
|
|
end)
|
|
end)
|
|
|
|
describe(':Pending edit -file', function()
|
|
it('clears file reference from task', function()
|
|
local pending = require('pending')
|
|
local t = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } })
|
|
s:save()
|
|
pending.edit(tostring(t.id), '-file')
|
|
s:load()
|
|
local updated = s:get(t.id)
|
|
assert.is_nil(updated._extra)
|
|
end)
|
|
|
|
it('shows feedback when file reference is removed', function()
|
|
local pending = require('pending')
|
|
local t = s:add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } })
|
|
s:save()
|
|
local messages = {}
|
|
local orig_notify = vim.notify
|
|
vim.notify = function(msg, level)
|
|
table.insert(messages, { msg = msg, level = level })
|
|
end
|
|
pending.edit(tostring(t.id), '-file')
|
|
vim.notify = orig_notify
|
|
assert.are.equal(1, #messages)
|
|
assert.truthy(messages[1].msg:find('file reference removed'))
|
|
end)
|
|
|
|
it('does not error when task has no file', function()
|
|
local pending = require('pending')
|
|
local t = s:add({ description = 'Task one' })
|
|
s:save()
|
|
assert.has_no_error(function()
|
|
pending.edit(tostring(t.id), '-file')
|
|
end)
|
|
end)
|
|
|
|
it('preserves other _extra fields when -file is used', function()
|
|
local pending = require('pending')
|
|
local t = s:add({
|
|
description = 'Task one',
|
|
_extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc' },
|
|
})
|
|
s:save()
|
|
pending.edit(tostring(t.id), '-file')
|
|
s:load()
|
|
local updated = s:get(t.id)
|
|
assert.is_not_nil(updated._extra)
|
|
assert.is_nil(updated._extra.file)
|
|
assert.are.equal('abc', updated._extra._gcal_event_id)
|
|
end)
|
|
end)
|
|
|
|
describe('goto_file', function()
|
|
it('notifies warn when task has no file attached', function()
|
|
local pending = require('pending')
|
|
local buffer = require('pending.buffer')
|
|
|
|
local t = s:add({ description = 'Task one' })
|
|
s:save()
|
|
|
|
buffer.set_store(s)
|
|
local bufnr = buffer.open()
|
|
vim.bo[bufnr].filetype = 'pending'
|
|
vim.api.nvim_set_current_buf(bufnr)
|
|
|
|
local meta = buffer.meta()
|
|
local task_lnum = nil
|
|
for lnum, m in ipairs(meta) do
|
|
if m.type == 'task' and m.id == t.id then
|
|
task_lnum = lnum
|
|
break
|
|
end
|
|
end
|
|
assert.is_not_nil(task_lnum)
|
|
vim.api.nvim_win_set_cursor(0, { task_lnum, 0 })
|
|
|
|
local messages = {}
|
|
local orig_notify = vim.notify
|
|
vim.notify = function(msg, level)
|
|
table.insert(messages, { msg = msg, level = level })
|
|
end
|
|
|
|
pending.goto_file()
|
|
|
|
vim.notify = orig_notify
|
|
|
|
local warned = false
|
|
for _, m in ipairs(messages) do
|
|
if m.level == vim.log.levels.WARN and m.msg:find('No file attached') then
|
|
warned = true
|
|
end
|
|
end
|
|
assert.is_true(warned)
|
|
|
|
vim.api.nvim_buf_delete(bufnr, { force = true })
|
|
end)
|
|
|
|
it('notifies error when file spec is unreadable', function()
|
|
local pending = require('pending')
|
|
local buffer = require('pending.buffer')
|
|
|
|
local t = s:add({
|
|
description = 'Task one',
|
|
_extra = { file = 'nonexistent/path.lua:1' },
|
|
})
|
|
s:save()
|
|
|
|
buffer.set_store(s)
|
|
local bufnr = buffer.open()
|
|
vim.bo[bufnr].filetype = 'pending'
|
|
vim.api.nvim_set_current_buf(bufnr)
|
|
|
|
local meta = buffer.meta()
|
|
local task_lnum = nil
|
|
for lnum, m in ipairs(meta) do
|
|
if m.type == 'task' and m.id == t.id then
|
|
task_lnum = lnum
|
|
break
|
|
end
|
|
end
|
|
assert.is_not_nil(task_lnum)
|
|
vim.api.nvim_win_set_cursor(0, { task_lnum, 0 })
|
|
|
|
local messages = {}
|
|
local orig_notify = vim.notify
|
|
vim.notify = function(msg, level)
|
|
table.insert(messages, { msg = msg, level = level })
|
|
end
|
|
|
|
pending.goto_file()
|
|
|
|
vim.notify = orig_notify
|
|
|
|
local errored = false
|
|
for _, m in ipairs(messages) do
|
|
if m.level == vim.log.levels.ERROR and m.msg:find('File not found') then
|
|
errored = true
|
|
end
|
|
end
|
|
assert.is_true(errored)
|
|
|
|
vim.api.nvim_buf_delete(bufnr, { force = true })
|
|
end)
|
|
end)
|
|
end)
|