pending.nvim/spec/store_spec.lua
Barrett Ruth 9af6086959
Some checks are pending
luarocks / quality (push) Waiting to run
luarocks / publish (push) Blocked by required conditions
feat(buffer): persist fold state across sessions (#94)
Problem: folded category headers are lost when Neovim exits because
`_fold_state` only lives in memory. Users must re-fold categories
every session.

Solution: store folded category names in the JSON data file as a
top-level `folded_categories` field. On first render, `restore_folds`
seeds from the store instead of the empty in-memory state. Folds are
persisted on `M.close()` and `VimLeavePre`.
2026-03-07 20:18:34 -05:00

280 lines
8 KiB
Lua

require('spec.helpers')
local config = require('pending.config')
local store = require('pending.store')
describe('store', function()
local tmpdir
local s
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
s = store.new(tmpdir .. '/tasks.json')
s:load()
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
config.reset()
end)
describe('load', function()
it('returns empty data when no file exists', function()
local data = s: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 = tmpdir .. '/tasks.json'
local f = io.open(path, 'w')
f:write(vim.json.encode({
version = 1,
next_id = 3,
tasks = {
{
id = 1,
description = 'Pending one',
status = 'pending',
entry = '2026-01-01T00:00:00Z',
modified = '2026-01-01T00:00:00Z',
},
{
id = 2,
description = 'Pending two',
status = 'done',
entry = '2026-01-01T00:00:00Z',
modified = '2026-01-01T00:00:00Z',
},
},
}))
f:close()
local data = s:load()
assert.are.equal(3, data.next_id)
assert.are.equal(2, #data.tasks)
assert.are.equal('Pending one', data.tasks[1].description)
assert.are.equal('done', data.tasks[2].status)
end)
it('preserves unknown fields', function()
local path = tmpdir .. '/tasks.json'
local f = io.open(path, 'w')
f:write(vim.json.encode({
version = 1,
next_id = 2,
tasks = {
{
id = 1,
description = 'Pending',
status = 'pending',
entry = '2026-01-01T00:00:00Z',
modified = '2026-01-01T00:00:00Z',
custom_field = 'hello',
},
},
}))
f:close()
s:load()
local task = s: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()
local t1 = s:add({ description = 'First' })
local t2 = s:add({ description = 'Second' })
assert.are.equal(1, t1.id)
assert.are.equal(2, t2.id)
assert.are.equal('pending', t1.status)
assert.are.equal('Todo', t1.category)
end)
it('uses provided category', function()
local t = s:add({ description = 'Test', category = 'Work' })
assert.are.equal('Work', t.category)
end)
end)
describe('update', function()
it('updates fields and sets modified', function()
local t = s:add({ description = 'Original' })
t.modified = '2025-01-01T00:00:00Z'
s:update(t.id, { description = 'Updated' })
local updated = s:get(t.id)
assert.are.equal('Updated', updated.description)
assert.is_not.equal('2025-01-01T00:00:00Z', updated.modified)
end)
it('sets end timestamp on completion', function()
local t = s:add({ description = 'Test' })
assert.is_nil(t['end'])
s:update(t.id, { status = 'done' })
local updated = s:get(t.id)
assert.is_not_nil(updated['end'])
end)
it('does not overwrite id or entry', function()
local t = s:add({ description = 'Immutable fields' })
local original_id = t.id
local original_entry = t.entry
s:update(t.id, { id = 999, entry = 'x' })
local updated = s:get(original_id)
assert.are.equal(original_id, updated.id)
assert.are.equal(original_entry, updated.entry)
end)
it('does not overwrite end on second completion', function()
local t = s:add({ description = 'Complete twice' })
s:update(t.id, { status = 'done', ['end'] = '2026-01-15T10:00:00Z' })
local first_end = s:get(t.id)['end']
s:update(t.id, { status = 'done' })
local task = s:get(t.id)
assert.are.equal(first_end, task['end'])
end)
end)
describe('delete', function()
it('marks task as deleted', function()
local t = s:add({ description = 'To delete' })
s:delete(t.id)
local deleted = s: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()
s:add({ description = 'Persisted', category = 'Work', priority = 1 })
s:save()
s:load()
local tasks = s: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 = tmpdir .. '/tasks.json'
local f = io.open(path, 'w')
f:write(vim.json.encode({
version = 1,
next_id = 2,
tasks = {
{
id = 1,
description = 'Pending',
status = 'pending',
entry = '2026-01-01T00:00:00Z',
modified = '2026-01-01T00:00:00Z',
_gcal_event_id = 'abc123',
},
},
}))
f:close()
s:load()
s:save()
s:load()
local task = s:get(1)
assert.are.equal('abc123', task._extra._gcal_event_id)
end)
end)
describe('recurrence fields', function()
it('persists recur and recur_mode through round-trip', function()
s:add({ description = 'Recurring', recur = 'weekly', recur_mode = 'scheduled' })
s:save()
s:load()
local task = s:get(1)
assert.are.equal('weekly', task.recur)
assert.are.equal('scheduled', task.recur_mode)
end)
it('persists recur without recur_mode', function()
s:add({ description = 'Simple recur', recur = 'daily' })
s:save()
s:load()
local task = s:get(1)
assert.are.equal('daily', task.recur)
assert.is_nil(task.recur_mode)
end)
it('omits recur fields when not set', function()
s:add({ description = 'No recur' })
s:save()
s:load()
local task = s:get(1)
assert.is_nil(task.recur)
assert.is_nil(task.recur_mode)
end)
end)
describe('folded_categories', function()
it('defaults to empty table when missing from JSON', function()
local path = tmpdir .. '/tasks.json'
local f = io.open(path, 'w')
f:write(vim.json.encode({
version = 1,
next_id = 1,
tasks = {},
}))
f:close()
s:load()
assert.are.same({}, s:get_folded_categories())
end)
it('round-trips folded categories through save and load', function()
s:set_folded_categories({ 'Work', 'Home' })
s:save()
s:load()
assert.are.same({ 'Work', 'Home' }, s:get_folded_categories())
end)
it('persists empty list', function()
s:set_folded_categories({})
s:save()
s:load()
assert.are.same({}, s:get_folded_categories())
end)
end)
describe('active_tasks', function()
it('excludes deleted tasks', function()
s:add({ description = 'Active' })
local t2 = s:add({ description = 'To delete' })
s:delete(t2.id)
local active = s:active_tasks()
assert.are.equal(1, #active)
assert.are.equal('Active', active[1].description)
end)
end)
describe('snapshot', function()
it('returns a table of tasks', function()
s:add({ description = 'Snap one' })
s:add({ description = 'Snap two' })
local snap = s:snapshot()
assert.are.equal(2, #snap)
end)
it('returns a copy that does not affect the store', function()
local t = s:add({ description = 'Original' })
local snap = s:snapshot()
snap[1].description = 'Mutated'
local live = s:get(t.id)
assert.are.equal('Original', live.description)
end)
it('excludes deleted tasks', function()
local t = s:add({ description = 'Will be deleted' })
s:delete(t.id)
local snap = s:snapshot()
assert.are.equal(0, #snap)
end)
end)
end)