Problem: the detail buffer rendered metadata as read-only virtual text overlays. Users could not edit status, priority, category, due, or recurrence from the detail view. Solution: render frontmatter as real `Key: value` text lines highlighted via extmarks. On `:w`, `parse_detail_frontmatter()` validates every field (status, priority bounds, `resolve_date`, `recur.validate`) and aborts with `log.error()` on any invalid input. Removing a line clears the field; editing the `# title` updates the description.
402 lines
11 KiB
Lua
402 lines
11 KiB
Lua
require('spec.helpers')
|
|
|
|
local config = require('pending.config')
|
|
|
|
describe('detail frontmatter', function()
|
|
local buffer
|
|
local tmpdir
|
|
|
|
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
|
|
buffer = require('pending.buffer')
|
|
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('build_detail_frontmatter', function()
|
|
it('renders status and priority for minimal task', function()
|
|
local lines = buffer._build_detail_frontmatter({
|
|
id = 1,
|
|
description = 'Test',
|
|
status = 'pending',
|
|
priority = 0,
|
|
entry = '',
|
|
modified = '',
|
|
order = 0,
|
|
})
|
|
assert.are.equal(2, #lines)
|
|
assert.are.equal('Status: pending', lines[1])
|
|
assert.are.equal('Priority: 0', lines[2])
|
|
end)
|
|
|
|
it('renders all fields', function()
|
|
local lines = buffer._build_detail_frontmatter({
|
|
id = 1,
|
|
description = 'Test',
|
|
status = 'wip',
|
|
priority = 2,
|
|
category = 'Work',
|
|
due = '2026-03-15',
|
|
recur = 'weekly',
|
|
entry = '',
|
|
modified = '',
|
|
order = 0,
|
|
})
|
|
assert.are.equal(5, #lines)
|
|
assert.are.equal('Status: wip', lines[1])
|
|
assert.are.equal('Priority: 2', lines[2])
|
|
assert.are.equal('Category: Work', lines[3])
|
|
assert.are.equal('Due: 2026-03-15', lines[4])
|
|
assert.are.equal('Recur: weekly', lines[5])
|
|
end)
|
|
|
|
it('prefixes recur with ! for completion mode', function()
|
|
local lines = buffer._build_detail_frontmatter({
|
|
id = 1,
|
|
description = 'Test',
|
|
status = 'pending',
|
|
priority = 0,
|
|
recur = 'daily',
|
|
recur_mode = 'completion',
|
|
entry = '',
|
|
modified = '',
|
|
order = 0,
|
|
})
|
|
assert.are.equal('Recur: !daily', lines[3])
|
|
end)
|
|
|
|
it('omits optional fields when absent', function()
|
|
local lines = buffer._build_detail_frontmatter({
|
|
id = 1,
|
|
description = 'Test',
|
|
status = 'done',
|
|
priority = 1,
|
|
entry = '',
|
|
modified = '',
|
|
order = 0,
|
|
})
|
|
assert.are.equal(2, #lines)
|
|
assert.are.equal('Status: done', lines[1])
|
|
assert.are.equal('Priority: 1', lines[2])
|
|
end)
|
|
end)
|
|
|
|
describe('parse_detail_frontmatter', function()
|
|
it('parses minimal frontmatter', function()
|
|
local lines = {
|
|
'# My task',
|
|
'Status: pending',
|
|
'Priority: 0',
|
|
'---',
|
|
'some notes',
|
|
}
|
|
local sep, fields, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.is_nil(err)
|
|
assert.are.equal(4, sep)
|
|
assert.are.equal('My task', fields.description)
|
|
assert.are.equal('pending', fields.status)
|
|
assert.are.equal(0, fields.priority)
|
|
end)
|
|
|
|
it('parses all fields', function()
|
|
local lines = {
|
|
'# Fix the bug',
|
|
'Status: wip',
|
|
'Priority: 2',
|
|
'Category: Work',
|
|
'Due: 2026-03-15',
|
|
'Recur: weekly',
|
|
'---',
|
|
}
|
|
local sep, fields, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.is_nil(err)
|
|
assert.are.equal(7, sep)
|
|
assert.are.equal('Fix the bug', fields.description)
|
|
assert.are.equal('wip', fields.status)
|
|
assert.are.equal(2, fields.priority)
|
|
assert.are.equal('Work', fields.category)
|
|
assert.are.equal('2026-03-15', fields.due)
|
|
assert.are.equal('weekly', fields.recur)
|
|
end)
|
|
|
|
it('resolves due date keywords', function()
|
|
local lines = {
|
|
'# Task',
|
|
'Status: pending',
|
|
'Priority: 0',
|
|
'Due: tomorrow',
|
|
'---',
|
|
}
|
|
local _, fields, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.is_nil(err)
|
|
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 })
|
|
)
|
|
assert.are.equal(expected, fields.due)
|
|
end)
|
|
|
|
it('parses completion-mode recurrence', function()
|
|
local lines = {
|
|
'# Task',
|
|
'Status: pending',
|
|
'Priority: 0',
|
|
'Recur: !daily',
|
|
'---',
|
|
}
|
|
local _, fields, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.is_nil(err)
|
|
assert.are.equal('daily', fields.recur)
|
|
assert.are.equal('completion', fields.recur_mode)
|
|
end)
|
|
|
|
it('clears optional fields when lines removed', function()
|
|
local lines = {
|
|
'# Task',
|
|
'Status: done',
|
|
'Priority: 1',
|
|
'---',
|
|
}
|
|
local _, fields, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.is_nil(err)
|
|
assert.are.equal(vim.NIL, fields.category)
|
|
assert.are.equal(vim.NIL, fields.due)
|
|
assert.are.equal(vim.NIL, fields.recur)
|
|
end)
|
|
|
|
it('skips blank lines in frontmatter', function()
|
|
local lines = {
|
|
'# Task',
|
|
'Status: pending',
|
|
'',
|
|
'Priority: 0',
|
|
'---',
|
|
}
|
|
local _, fields, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.is_nil(err)
|
|
assert.are.equal('pending', fields.status)
|
|
assert.are.equal(0, fields.priority)
|
|
end)
|
|
|
|
it('errors on missing separator', function()
|
|
local lines = {
|
|
'# Task',
|
|
'Status: pending',
|
|
'Priority: 0',
|
|
}
|
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.truthy(err:find('missing separator'))
|
|
end)
|
|
|
|
it('errors on missing title', function()
|
|
local lines = {
|
|
'',
|
|
'Status: pending',
|
|
'---',
|
|
}
|
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.truthy(err:find('missing or empty title'))
|
|
end)
|
|
|
|
it('errors on empty title', function()
|
|
local lines = {
|
|
'# ',
|
|
'Status: pending',
|
|
'---',
|
|
}
|
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.truthy(err:find('missing or empty title'))
|
|
end)
|
|
|
|
it('errors on invalid status', function()
|
|
local lines = {
|
|
'# Task',
|
|
'Status: bogus',
|
|
'Priority: 0',
|
|
'---',
|
|
}
|
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.truthy(err:find('invalid status'))
|
|
end)
|
|
|
|
it('errors on negative priority', function()
|
|
local lines = {
|
|
'# Task',
|
|
'Status: pending',
|
|
'Priority: -1',
|
|
'---',
|
|
}
|
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.truthy(err:find('invalid priority'))
|
|
end)
|
|
|
|
it('errors on non-integer priority', function()
|
|
local lines = {
|
|
'# Task',
|
|
'Status: pending',
|
|
'Priority: 1.5',
|
|
'---',
|
|
}
|
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.truthy(err:find('invalid priority'))
|
|
end)
|
|
|
|
it('errors on priority exceeding max', function()
|
|
local lines = {
|
|
'# Task',
|
|
'Status: pending',
|
|
'Priority: 4',
|
|
'---',
|
|
}
|
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.truthy(err:find('max is 3'))
|
|
end)
|
|
|
|
it('errors on invalid due date', function()
|
|
local lines = {
|
|
'# Task',
|
|
'Status: pending',
|
|
'Priority: 0',
|
|
'Due: notadate',
|
|
'---',
|
|
}
|
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.truthy(err:find('invalid due date'))
|
|
end)
|
|
|
|
it('errors on empty due value', function()
|
|
local lines = {
|
|
'# Task',
|
|
'Due: ',
|
|
'---',
|
|
}
|
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.truthy(err:find('empty due value'))
|
|
end)
|
|
|
|
it('errors on invalid recurrence', function()
|
|
local lines = {
|
|
'# Task',
|
|
'Recur: nope',
|
|
'---',
|
|
}
|
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.truthy(err:find('invalid recurrence'))
|
|
end)
|
|
|
|
it('errors on empty recur value', function()
|
|
local lines = {
|
|
'# Task',
|
|
'Recur: ',
|
|
'---',
|
|
}
|
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.truthy(err:find('empty recur value'))
|
|
end)
|
|
|
|
it('errors on empty category value', function()
|
|
local lines = {
|
|
'# Task',
|
|
'Category: ',
|
|
'---',
|
|
}
|
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.truthy(err:find('empty category'))
|
|
end)
|
|
|
|
it('errors on unknown field', function()
|
|
local lines = {
|
|
'# Task',
|
|
'Status: pending',
|
|
'Foo: bar',
|
|
'---',
|
|
}
|
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.truthy(err:find('unknown field: foo'))
|
|
end)
|
|
|
|
it('errors on duplicate field', function()
|
|
local lines = {
|
|
'# Task',
|
|
'Status: pending',
|
|
'Status: done',
|
|
'---',
|
|
}
|
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.truthy(err:find('duplicate field'))
|
|
end)
|
|
|
|
it('errors on malformed frontmatter line', function()
|
|
local lines = {
|
|
'# Task',
|
|
'not a key value pair',
|
|
'---',
|
|
}
|
|
local _, _, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.truthy(err:find('invalid frontmatter line'))
|
|
end)
|
|
|
|
it('is case-insensitive for field keys', function()
|
|
local lines = {
|
|
'# Task',
|
|
'STATUS: wip',
|
|
'PRIORITY: 1',
|
|
'CATEGORY: Work',
|
|
'---',
|
|
}
|
|
local _, fields, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.is_nil(err)
|
|
assert.are.equal('wip', fields.status)
|
|
assert.are.equal(1, fields.priority)
|
|
assert.are.equal('Work', fields.category)
|
|
end)
|
|
|
|
it('accepts datetime due format', function()
|
|
local lines = {
|
|
'# Task',
|
|
'Due: 2026-03-15T14:00',
|
|
'---',
|
|
}
|
|
local _, fields, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.is_nil(err)
|
|
assert.are.equal('2026-03-15T14:00', fields.due)
|
|
end)
|
|
|
|
it('respects custom max_priority', function()
|
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json', max_priority = 5 }
|
|
config.reset()
|
|
local lines = {
|
|
'# Task',
|
|
'Priority: 5',
|
|
'---',
|
|
}
|
|
local _, fields, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.is_nil(err)
|
|
assert.are.equal(5, fields.priority)
|
|
end)
|
|
|
|
it('updates description from title line', function()
|
|
local lines = {
|
|
'# Updated title',
|
|
'Status: pending',
|
|
'Priority: 0',
|
|
'---',
|
|
}
|
|
local _, fields, err = buffer._parse_detail_frontmatter(lines)
|
|
assert.is_nil(err)
|
|
assert.are.equal('Updated title', fields.description)
|
|
end)
|
|
end)
|
|
end)
|