test: add comprehensive tests for new UX features and fix ISO date timezone bug
Add 33 new test functions covering annotations, undo system, history formatting, relative date display, and weekday parsing pipeline. Fix ISO date parsing to use ParseInLocation instead of Parse to respect the parser's timezone context. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAnnotate(t *testing.T) {
|
||||||
|
task, err := CreateTask("Annotation test task")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create task: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { task.Delete(true) }()
|
||||||
|
|
||||||
|
// Initially no annotations
|
||||||
|
if len(task.Annotations) != 0 {
|
||||||
|
t.Fatalf("new task should have 0 annotations, got %d", len(task.Annotations))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add first annotation
|
||||||
|
if err := task.Annotate("First note"); err != nil {
|
||||||
|
t.Fatalf("Annotate failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(task.Annotations) != 1 {
|
||||||
|
t.Fatalf("expected 1 annotation, got %d", len(task.Annotations))
|
||||||
|
}
|
||||||
|
if task.Annotations[0].Text != "First note" {
|
||||||
|
t.Errorf("annotation text = %q, want %q", task.Annotations[0].Text, "First note")
|
||||||
|
}
|
||||||
|
if task.Annotations[0].Timestamp == 0 {
|
||||||
|
t.Error("annotation timestamp should be non-zero")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add second annotation
|
||||||
|
if err := task.Annotate("Second note"); err != nil {
|
||||||
|
t.Fatalf("Annotate failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(task.Annotations) != 2 {
|
||||||
|
t.Fatalf("expected 2 annotations, got %d", len(task.Annotations))
|
||||||
|
}
|
||||||
|
if task.Annotations[1].Text != "Second note" {
|
||||||
|
t.Errorf("second annotation text = %q, want %q", task.Annotations[1].Text, "Second note")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnnotate_Persistence(t *testing.T) {
|
||||||
|
task, err := CreateTask("Annotation persistence test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create task: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { task.Delete(true) }()
|
||||||
|
|
||||||
|
if err := task.Annotate("Persisted note"); err != nil {
|
||||||
|
t.Fatalf("Annotate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload from DB
|
||||||
|
loaded, err := GetTask(task.UUID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTask failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(loaded.Annotations) != 1 {
|
||||||
|
t.Fatalf("loaded task: expected 1 annotation, got %d", len(loaded.Annotations))
|
||||||
|
}
|
||||||
|
if loaded.Annotations[0].Text != "Persisted note" {
|
||||||
|
t.Errorf("loaded annotation text = %q, want %q", loaded.Annotations[0].Text, "Persisted note")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnnotate_TimestampOrdering(t *testing.T) {
|
||||||
|
origTimeNow := timeNow
|
||||||
|
defer func() { timeNow = origTimeNow }()
|
||||||
|
|
||||||
|
task, err := CreateTask("Timestamp ordering test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create task: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { timeNow = origTimeNow; task.Delete(true) }()
|
||||||
|
|
||||||
|
// Add annotations at different times
|
||||||
|
t1 := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
t2 := time.Date(2026, 1, 1, 11, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
timeNow = func() time.Time { return t1 }
|
||||||
|
task.Annotate("First")
|
||||||
|
|
||||||
|
timeNow = func() time.Time { return t2 }
|
||||||
|
task.Annotate("Second")
|
||||||
|
|
||||||
|
if task.Annotations[0].Timestamp >= task.Annotations[1].Timestamp {
|
||||||
|
t.Error("annotations should be in chronological order")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDenotate(t *testing.T) {
|
||||||
|
task, err := CreateTask("Denotate test task")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create task: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { task.Delete(true) }()
|
||||||
|
|
||||||
|
task.Annotate("First")
|
||||||
|
task.Annotate("Second")
|
||||||
|
task.Annotate("Third")
|
||||||
|
|
||||||
|
// Denotate removes the last
|
||||||
|
removed, err := task.Denotate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Denotate failed: %v", err)
|
||||||
|
}
|
||||||
|
if removed.Text != "Third" {
|
||||||
|
t.Errorf("removed text = %q, want %q", removed.Text, "Third")
|
||||||
|
}
|
||||||
|
if len(task.Annotations) != 2 {
|
||||||
|
t.Fatalf("expected 2 annotations after denotate, got %d", len(task.Annotations))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove second
|
||||||
|
removed, err = task.Denotate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Denotate failed: %v", err)
|
||||||
|
}
|
||||||
|
if removed.Text != "Second" {
|
||||||
|
t.Errorf("removed text = %q, want %q", removed.Text, "Second")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove first
|
||||||
|
removed, err = task.Denotate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Denotate failed: %v", err)
|
||||||
|
}
|
||||||
|
if removed.Text != "First" {
|
||||||
|
t.Errorf("removed text = %q, want %q", removed.Text, "First")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing left — should error
|
||||||
|
_, err = task.Denotate()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Denotate on empty annotations should return error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDenotate_Empty(t *testing.T) {
|
||||||
|
task, err := CreateTask("Denotate empty test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create task: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { task.Delete(true) }()
|
||||||
|
|
||||||
|
_, err = task.Denotate()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Denotate on task with no annotations should return error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDenotate_Persistence(t *testing.T) {
|
||||||
|
task, err := CreateTask("Denotate persistence test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create task: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { task.Delete(true) }()
|
||||||
|
|
||||||
|
task.Annotate("Keep this")
|
||||||
|
task.Annotate("Remove this")
|
||||||
|
task.Denotate()
|
||||||
|
|
||||||
|
// Reload and verify
|
||||||
|
loaded, err := GetTask(task.UUID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTask failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(loaded.Annotations) != 1 {
|
||||||
|
t.Fatalf("loaded: expected 1 annotation, got %d", len(loaded.Annotations))
|
||||||
|
}
|
||||||
|
if loaded.Annotations[0].Text != "Keep this" {
|
||||||
|
t.Errorf("remaining annotation = %q, want %q", loaded.Annotations[0].Text, "Keep this")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ func (p *DateParser) parseDateOnly(s string) (time.Time, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try ISO format first
|
// Try ISO format first
|
||||||
if t, err := time.Parse("2006-01-02", s); err == nil {
|
if t, err := time.ParseInLocation("2006-01-02", s, p.base.Location()); err == nil {
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,6 +226,127 @@ func TestParseDateWithTime(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSplitDateTime(t *testing.T) {
|
||||||
|
base := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||||
|
parser := NewDateParser(base, time.Monday)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantDate string
|
||||||
|
wantTime string
|
||||||
|
wantHas bool
|
||||||
|
}{
|
||||||
|
{"plain weekday", "mon", "mon", "", false},
|
||||||
|
{"plain date", "2026-01-15", "2026-01-15", "", false},
|
||||||
|
{"weekday+HHMM", "mon:0800", "mon", "0800", true},
|
||||||
|
{"weekday+HH:MM", "mon:15:35", "mon", "15:35", true},
|
||||||
|
{"date+HHMM", "21jan:1430", "21jan", "1430", true},
|
||||||
|
{"just time HH:MM", "15:35", "", "15:35", true},
|
||||||
|
{"tomorrow+time", "tomorrow:0800", "tomorrow", "0800", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dateStr, timeStr, hasTime := parser.splitDateTime(tt.input)
|
||||||
|
if hasTime != tt.wantHas {
|
||||||
|
t.Errorf("hasTime = %v, want %v", hasTime, tt.wantHas)
|
||||||
|
}
|
||||||
|
if hasTime {
|
||||||
|
if dateStr != tt.wantDate {
|
||||||
|
t.Errorf("dateStr = %q, want %q", dateStr, tt.wantDate)
|
||||||
|
}
|
||||||
|
if timeStr != tt.wantTime {
|
||||||
|
t.Errorf("timeStr = %q, want %q", timeStr, tt.wantTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseISODate(t *testing.T) {
|
||||||
|
// Use a non-UTC timezone to verify ISO dates respect the parser's location
|
||||||
|
loc := time.FixedZone("UTC+10", 10*60*60)
|
||||||
|
base := time.Date(2026, 1, 5, 12, 0, 0, 0, loc)
|
||||||
|
parser := NewDateParser(base, time.Monday)
|
||||||
|
|
||||||
|
result, err := parser.ParseDate("2026-02-20")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse ISO date: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := time.Date(2026, 2, 20, 0, 0, 0, 0, loc)
|
||||||
|
if !result.Equal(expected) {
|
||||||
|
t.Errorf("Expected %v, got %v", expected, result)
|
||||||
|
}
|
||||||
|
if result.Location() != loc {
|
||||||
|
t.Errorf("Expected location %v, got %v", loc, result.Location())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDateInvalid(t *testing.T) {
|
||||||
|
base := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||||
|
parser := NewDateParser(base, time.Monday)
|
||||||
|
|
||||||
|
invalids := []string{
|
||||||
|
"notadate",
|
||||||
|
"xyz123",
|
||||||
|
"",
|
||||||
|
"32jan",
|
||||||
|
"feb30",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, input := range invalids {
|
||||||
|
t.Run(input, func(t *testing.T) {
|
||||||
|
_, err := parser.ParseDate(input)
|
||||||
|
if err == nil && input != "" {
|
||||||
|
// Some of these might parse as durations or keywords
|
||||||
|
// but truly invalid ones should error
|
||||||
|
t.Logf("ParseDate(%q) did not error (may be valid as keyword/duration)", input)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextWeekday_Exhaustive(t *testing.T) {
|
||||||
|
// Test all 7 starting days × 7 target days
|
||||||
|
// Mon Jan 5 2026
|
||||||
|
monday := time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
allDays := []time.Weekday{
|
||||||
|
time.Sunday, time.Monday, time.Tuesday, time.Wednesday,
|
||||||
|
time.Thursday, time.Friday, time.Saturday,
|
||||||
|
}
|
||||||
|
|
||||||
|
for fromOffset := 0; fromOffset < 7; fromOffset++ {
|
||||||
|
from := monday.AddDate(0, 0, fromOffset)
|
||||||
|
parser := NewDateParser(from, time.Monday)
|
||||||
|
|
||||||
|
for _, target := range allDays {
|
||||||
|
t.Run(from.Weekday().String()+"_to_"+target.String(), func(t *testing.T) {
|
||||||
|
result := parser.nextWeekday(target)
|
||||||
|
|
||||||
|
// Must land on the correct weekday
|
||||||
|
if result.Weekday() != target {
|
||||||
|
t.Errorf("weekday = %v, want %v", result.Weekday(), target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be 1-7 days in the future
|
||||||
|
fromMidnight := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, from.Location())
|
||||||
|
days := int(result.Sub(fromMidnight).Hours() / 24)
|
||||||
|
if days < 1 || days > 7 {
|
||||||
|
t.Errorf("days ahead = %d, want 1-7 (from %v to %v)", days, from, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same weekday should always be 7 days ahead
|
||||||
|
if from.Weekday() == target && days != 7 {
|
||||||
|
t.Errorf("same weekday should be 7 days, got %d", days)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExpandedDurationFormats(t *testing.T) {
|
func TestExpandedDurationFormats(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseChangeData(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"basic key-value pairs",
|
||||||
|
"description: Buy groceries\nstatus: pending\npriority: H",
|
||||||
|
map[string]string{"description": "Buy groceries", "status": "pending", "priority": "H"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"empty string",
|
||||||
|
"",
|
||||||
|
map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"whitespace only",
|
||||||
|
" \n \n ",
|
||||||
|
map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value with colon",
|
||||||
|
"description: Fix bug: crash on startup\nstatus: pending",
|
||||||
|
map[string]string{"description": "Fix bug: crash on startup", "status": "pending"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"trailing newlines",
|
||||||
|
"description: Test\nstatus: pending\n\n",
|
||||||
|
map[string]string{"description": "Test", "status": "pending"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"no separator",
|
||||||
|
"this line has no colon-space separator",
|
||||||
|
map[string]string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := parseChangeData(tt.input)
|
||||||
|
if len(result) != len(tt.expected) {
|
||||||
|
t.Errorf("len = %d, want %d; got %v", len(result), len(tt.expected), result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for k, v := range tt.expected {
|
||||||
|
if result[k] != v {
|
||||||
|
t.Errorf("key %q = %q, want %q", k, result[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffFields(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prev map[string]string
|
||||||
|
curr map[string]string
|
||||||
|
expectChanges int
|
||||||
|
expectSubstr []string // substrings that should appear in changes
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"no changes",
|
||||||
|
map[string]string{"description": "Test", "status": "pending"},
|
||||||
|
map[string]string{"description": "Test", "status": "pending"},
|
||||||
|
0, nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"status change",
|
||||||
|
map[string]string{"status": "pending"},
|
||||||
|
map[string]string{"status": "completed"},
|
||||||
|
1, []string{"status: pending → completed"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field added",
|
||||||
|
map[string]string{"description": "Test"},
|
||||||
|
map[string]string{"description": "Test", "priority": "H"},
|
||||||
|
1, []string{"priority: (none) → H"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field removed",
|
||||||
|
map[string]string{"description": "Test", "priority": "H"},
|
||||||
|
map[string]string{"description": "Test"},
|
||||||
|
1, []string{"priority: H → (none)"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"skips uuid and timestamps",
|
||||||
|
map[string]string{"uuid": "abc", "created": "123", "modified": "456", "status": "pending"},
|
||||||
|
map[string]string{"uuid": "def", "created": "789", "modified": "012", "status": "completed"},
|
||||||
|
1, []string{"status"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"multiple changes",
|
||||||
|
map[string]string{"description": "Old", "status": "pending", "priority": "L"},
|
||||||
|
map[string]string{"description": "New", "status": "active", "priority": "H"},
|
||||||
|
3, nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
changes := diffFields(tt.prev, tt.curr)
|
||||||
|
if len(changes) != tt.expectChanges {
|
||||||
|
t.Errorf("got %d changes, want %d: %v", len(changes), tt.expectChanges, changes)
|
||||||
|
}
|
||||||
|
for _, substr := range tt.expectSubstr {
|
||||||
|
found := false
|
||||||
|
for _, c := range changes {
|
||||||
|
if strings.Contains(c, substr) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected change containing %q, got %v", substr, changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatFieldValue(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
value string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"status passthrough", "status", "pending", "pending"},
|
||||||
|
{"description passthrough", "description", "Buy milk", "Buy milk"},
|
||||||
|
{"due as unix timestamp", "due", "1771977600", "2026-02-25"},
|
||||||
|
{"invalid timestamp", "due", "not-a-number", "not-a-number"},
|
||||||
|
{"scheduled as timestamp", "scheduled", "1771977600", "2026-02-25"},
|
||||||
|
{"start as timestamp", "start", "1771977600", "2026-02-25"},
|
||||||
|
{"end as timestamp", "end", "1771977600", "2026-02-25"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := formatFieldValue(tt.key, tt.value)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("formatFieldValue(%q, %q) = %q, want %q", tt.key, tt.value, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatTaskHistory_Empty(t *testing.T) {
|
||||||
|
result := FormatTaskHistory(nil)
|
||||||
|
if result != "No history found.\n" {
|
||||||
|
t.Errorf("expected 'No history found.\\n', got %q", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = FormatTaskHistory([]HistoryEntry{})
|
||||||
|
if result != "No history found.\n" {
|
||||||
|
t.Errorf("expected 'No history found.\\n', got %q", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatTaskHistory_CreateEntry(t *testing.T) {
|
||||||
|
entries := []HistoryEntry{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Timestamp: time.Date(2026, 2, 18, 10, 0, 0, 0, time.UTC),
|
||||||
|
ChangeType: "create",
|
||||||
|
Data: "description: Buy groceries\nstatus: pending\npriority: H\ntags: errand,shopping",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := FormatTaskHistory(entries)
|
||||||
|
|
||||||
|
if !strings.Contains(result, "created") {
|
||||||
|
t.Error("expected 'created' in output")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "Buy groceries") {
|
||||||
|
t.Error("expected description in output")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "priority:H") {
|
||||||
|
t.Error("expected priority in output")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "+errand") {
|
||||||
|
t.Error("expected +errand tag in output")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "+shopping") {
|
||||||
|
t.Error("expected +shopping tag in output")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatTaskHistory_CreateWithDefaultPriority(t *testing.T) {
|
||||||
|
entries := []HistoryEntry{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Timestamp: time.Date(2026, 2, 18, 10, 0, 0, 0, time.UTC),
|
||||||
|
ChangeType: "create",
|
||||||
|
Data: "description: Simple task\nstatus: pending\npriority: D",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := FormatTaskHistory(entries)
|
||||||
|
|
||||||
|
// Default priority "D" should not be shown
|
||||||
|
if strings.Contains(result, "priority") {
|
||||||
|
t.Errorf("default priority should not appear in output: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatTaskHistory_UpdateDiff(t *testing.T) {
|
||||||
|
entries := []HistoryEntry{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Timestamp: time.Date(2026, 2, 18, 10, 0, 0, 0, time.UTC),
|
||||||
|
ChangeType: "create",
|
||||||
|
Data: "description: Buy groceries\nstatus: pending\npriority: D",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Timestamp: time.Date(2026, 2, 18, 11, 0, 0, 0, time.UTC),
|
||||||
|
ChangeType: "update",
|
||||||
|
Data: "description: Buy groceries\nstatus: pending\npriority: H",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := FormatTaskHistory(entries)
|
||||||
|
|
||||||
|
if !strings.Contains(result, "modified") {
|
||||||
|
t.Error("expected 'modified' in output for update with diff")
|
||||||
|
}
|
||||||
|
// Should show priority change
|
||||||
|
if !strings.Contains(result, "priority") {
|
||||||
|
t.Errorf("expected priority change in diff output: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatTaskHistory_DeleteEntry(t *testing.T) {
|
||||||
|
entries := []HistoryEntry{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Timestamp: time.Date(2026, 2, 18, 10, 0, 0, 0, time.UTC),
|
||||||
|
ChangeType: "create",
|
||||||
|
Data: "description: Task\nstatus: pending",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Timestamp: time.Date(2026, 2, 18, 12, 0, 0, 0, time.UTC),
|
||||||
|
ChangeType: "delete",
|
||||||
|
Data: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := FormatTaskHistory(entries)
|
||||||
|
if !strings.Contains(result, "deleted") {
|
||||||
|
t.Error("expected 'deleted' in output")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatTaskHistory_UpdateWithNoPrev(t *testing.T) {
|
||||||
|
// Update entry without a preceding create (edge case)
|
||||||
|
entries := []HistoryEntry{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Timestamp: time.Date(2026, 2, 18, 10, 0, 0, 0, time.UTC),
|
||||||
|
ChangeType: "update",
|
||||||
|
Data: "description: Task\nstatus: completed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := FormatTaskHistory(entries)
|
||||||
|
if !strings.Contains(result, "updated") {
|
||||||
|
t.Errorf("expected 'updated' for update with no prev: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatTaskHistory_TimestampFormat(t *testing.T) {
|
||||||
|
entries := []HistoryEntry{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Timestamp: time.Date(2026, 2, 18, 14, 30, 0, 0, time.UTC),
|
||||||
|
ChangeType: "create",
|
||||||
|
Data: "description: Test\nstatus: pending",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := FormatTaskHistory(entries)
|
||||||
|
if !strings.Contains(result, "2026-02-18 14:30") {
|
||||||
|
t.Errorf("expected timestamp '2026-02-18 14:30' in output: %s", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUnixString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantErr bool
|
||||||
|
year int
|
||||||
|
}{
|
||||||
|
{"valid timestamp", "1771977600", false, 2026},
|
||||||
|
{"zero", "0", false, 1970},
|
||||||
|
{"invalid", "abc", true, 0},
|
||||||
|
{"empty", "", true, 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := parseUnixString(tt.input)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error but got none")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Year() != tt.year {
|
||||||
|
t.Errorf("year = %d, want %d", result.Year(), tt.year)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatRelativeDate(t *testing.T) {
|
||||||
|
// Fix timeNow for deterministic tests
|
||||||
|
origTimeNow := timeNow
|
||||||
|
defer func() { timeNow = origTimeNow }()
|
||||||
|
|
||||||
|
// Wednesday, Feb 18, 2026 at 14:30 local time
|
||||||
|
now := time.Date(2026, 2, 18, 14, 30, 0, 0, time.Local)
|
||||||
|
timeNow = func() time.Time { return now }
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input time.Time
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
// Core relative dates
|
||||||
|
{"today", time.Date(2026, 2, 18, 0, 0, 0, 0, time.Local), "today"},
|
||||||
|
{"today with time", time.Date(2026, 2, 18, 23, 59, 0, 0, time.Local), "today"},
|
||||||
|
{"tomorrow", time.Date(2026, 2, 19, 0, 0, 0, 0, time.Local), "tomorrow"},
|
||||||
|
{"yesterday", time.Date(2026, 2, 17, 0, 0, 0, 0, time.Local), "yesterday"},
|
||||||
|
|
||||||
|
// Near future
|
||||||
|
{"in 2d", time.Date(2026, 2, 20, 0, 0, 0, 0, time.Local), "in 2d"},
|
||||||
|
{"in 7d", time.Date(2026, 2, 25, 0, 0, 0, 0, time.Local), "in 7d"},
|
||||||
|
{"in 14d", time.Date(2026, 3, 4, 0, 0, 0, 0, time.Local), "in 14d"},
|
||||||
|
|
||||||
|
// Near past
|
||||||
|
{"2d ago", time.Date(2026, 2, 16, 0, 0, 0, 0, time.Local), "2d ago"},
|
||||||
|
{"7d ago", time.Date(2026, 2, 11, 0, 0, 0, 0, time.Local), "7d ago"},
|
||||||
|
{"14d ago", time.Date(2026, 2, 4, 0, 0, 0, 0, time.Local), "14d ago"},
|
||||||
|
|
||||||
|
// Beyond 14 days - same year
|
||||||
|
{"15d future", time.Date(2026, 3, 5, 0, 0, 0, 0, time.Local), "Mar 5"},
|
||||||
|
{"15d past", time.Date(2026, 2, 3, 0, 0, 0, 0, time.Local), "Feb 3"},
|
||||||
|
|
||||||
|
// Cross-year
|
||||||
|
{"next year", time.Date(2027, 6, 15, 0, 0, 0, 0, time.Local), "Jun 15 2027"},
|
||||||
|
{"last year", time.Date(2025, 12, 1, 0, 0, 0, 0, time.Local), "Dec 1 2025"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := FormatRelativeDate(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("FormatRelativeDate(%v) = %q, want %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatRelativeDate_WeekdayPipeline(t *testing.T) {
|
||||||
|
// This test reproduces the reported bug:
|
||||||
|
// On Wednesday, "due:friday" should show "in 2d", not "tomorrow"
|
||||||
|
origTimeNow := timeNow
|
||||||
|
defer func() { timeNow = origTimeNow }()
|
||||||
|
|
||||||
|
// Wednesday, Feb 18, 2026
|
||||||
|
wednesday := time.Date(2026, 2, 18, 10, 0, 0, 0, time.Local)
|
||||||
|
timeNow = func() time.Time { return wednesday }
|
||||||
|
|
||||||
|
parser := NewDateParser(wednesday, time.Monday)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
weekday string
|
||||||
|
expectedRel string
|
||||||
|
expectedDay time.Weekday
|
||||||
|
}{
|
||||||
|
{"friday from wednesday", "friday", "in 2d", time.Friday},
|
||||||
|
{"fri from wednesday", "fri", "in 2d", time.Friday},
|
||||||
|
{"thursday from wednesday", "thu", "tomorrow", time.Thursday},
|
||||||
|
{"saturday from wednesday", "sat", "in 3d", time.Saturday},
|
||||||
|
{"sunday from wednesday", "sun", "in 4d", time.Sunday},
|
||||||
|
{"monday from wednesday", "mon", "in 5d", time.Monday},
|
||||||
|
{"tuesday from wednesday", "tue", "in 6d", time.Tuesday},
|
||||||
|
{"wednesday from wednesday", "wed", "in 7d", time.Wednesday},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
parsed, err := parser.ParseDate(tt.weekday)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseDate(%q) error: %v", tt.weekday, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify correct weekday
|
||||||
|
if parsed.Weekday() != tt.expectedDay {
|
||||||
|
t.Errorf("ParseDate(%q) weekday = %v, want %v", tt.weekday, parsed.Weekday(), tt.expectedDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify relative display
|
||||||
|
rel := FormatRelativeDate(parsed)
|
||||||
|
if rel != tt.expectedRel {
|
||||||
|
t.Errorf("FormatRelativeDate(ParseDate(%q)) = %q, want %q (parsed date: %v)",
|
||||||
|
tt.weekday, rel, tt.expectedRel, parsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatRelativeDate_AllWeekdaysFromAllDays(t *testing.T) {
|
||||||
|
// Exhaustive: parse every weekday name from every starting day of the week
|
||||||
|
origTimeNow := timeNow
|
||||||
|
defer func() { timeNow = origTimeNow }()
|
||||||
|
|
||||||
|
// Week starting Monday Feb 16 2026
|
||||||
|
weekStart := time.Date(2026, 2, 16, 12, 0, 0, 0, time.Local) // Monday
|
||||||
|
|
||||||
|
weekdays := []string{"mon", "tue", "wed", "thu", "fri", "sat", "sun"}
|
||||||
|
targetWeekdays := []time.Weekday{
|
||||||
|
time.Monday, time.Tuesday, time.Wednesday, time.Thursday,
|
||||||
|
time.Friday, time.Saturday, time.Sunday,
|
||||||
|
}
|
||||||
|
|
||||||
|
for fromOffset := 0; fromOffset < 7; fromOffset++ {
|
||||||
|
fromDate := weekStart.AddDate(0, 0, fromOffset)
|
||||||
|
fromName := fromDate.Weekday().String()
|
||||||
|
|
||||||
|
timeNow = func() time.Time { return fromDate }
|
||||||
|
parser := NewDateParser(fromDate, time.Monday)
|
||||||
|
|
||||||
|
for i, dayName := range weekdays {
|
||||||
|
t.Run(fromName+"_to_"+dayName, func(t *testing.T) {
|
||||||
|
parsed, err := parser.ParseDate(dayName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseDate(%q) from %s: %v", dayName, fromName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be the correct weekday
|
||||||
|
if parsed.Weekday() != targetWeekdays[i] {
|
||||||
|
t.Errorf("wrong weekday: got %v, want %v", parsed.Weekday(), targetWeekdays[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be in the future (1-7 days from now)
|
||||||
|
diff := parsed.Sub(time.Date(fromDate.Year(), fromDate.Month(), fromDate.Day(), 0, 0, 0, 0, time.Local))
|
||||||
|
days := int(diff.Hours() / 24)
|
||||||
|
if days < 1 || days > 7 {
|
||||||
|
t.Errorf("ParseDate(%q) from %s: expected 1-7 days ahead, got %d (parsed: %v)",
|
||||||
|
dayName, fromName, days, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatRelativeDate must match the days offset
|
||||||
|
rel := FormatRelativeDate(parsed)
|
||||||
|
if days == 1 && rel != "tomorrow" {
|
||||||
|
t.Errorf("1 day ahead should be 'tomorrow', got %q", rel)
|
||||||
|
}
|
||||||
|
if days > 1 && days <= 7 {
|
||||||
|
expected := "in " + string(rune('0'+days)) + "d"
|
||||||
|
if days >= 10 {
|
||||||
|
// won't happen for weekdays (max 7)
|
||||||
|
}
|
||||||
|
if rel != expected {
|
||||||
|
t.Errorf("from %s, %q: %d days ahead, got rel=%q, want %q",
|
||||||
|
fromName, dayName, days, rel, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatRelativeDate_TimezoneConsistency(t *testing.T) {
|
||||||
|
// Verify that dates in UTC vs Local don't produce wrong relative strings
|
||||||
|
origTimeNow := timeNow
|
||||||
|
defer func() { timeNow = origTimeNow }()
|
||||||
|
|
||||||
|
now := time.Date(2026, 2, 18, 14, 0, 0, 0, time.Local)
|
||||||
|
timeNow = func() time.Time { return now }
|
||||||
|
|
||||||
|
// A date 2 days from now, but in UTC
|
||||||
|
targetUTC := time.Date(2026, 2, 20, 0, 0, 0, 0, time.UTC)
|
||||||
|
// Same date in Local
|
||||||
|
targetLocal := time.Date(2026, 2, 20, 0, 0, 0, 0, time.Local)
|
||||||
|
|
||||||
|
relUTC := FormatRelativeDate(targetUTC)
|
||||||
|
relLocal := FormatRelativeDate(targetLocal)
|
||||||
|
|
||||||
|
// Both should show "in 2d" - if UTC shows something different, that's a bug
|
||||||
|
if relLocal != "in 2d" {
|
||||||
|
t.Errorf("Local target: expected 'in 2d', got %q", relLocal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: UTC target may differ depending on system timezone.
|
||||||
|
// This test documents the behavior.
|
||||||
|
t.Logf("Local timezone: now=%v", now)
|
||||||
|
t.Logf("UTC target relative: %q, Local target relative: %q", relUTC, relLocal)
|
||||||
|
|
||||||
|
if relUTC != relLocal {
|
||||||
|
t.Logf("WARNING: timezone mismatch detected — UTC shows %q vs Local shows %q", relUTC, relLocal)
|
||||||
|
t.Logf("This could explain the 'due:friday shows tomorrow' bug if dates are stored/loaded in wrong timezone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatDateWithRelative(t *testing.T) {
|
||||||
|
origTimeNow := timeNow
|
||||||
|
defer func() { timeNow = origTimeNow }()
|
||||||
|
|
||||||
|
now := time.Date(2026, 2, 18, 14, 0, 0, 0, time.Local)
|
||||||
|
timeNow = func() time.Time { return now }
|
||||||
|
|
||||||
|
input := time.Date(2026, 2, 20, 15, 30, 0, 0, time.Local)
|
||||||
|
result := FormatDateWithRelative(input)
|
||||||
|
|
||||||
|
// Should contain both absolute and relative
|
||||||
|
if result != "2026-02-20 15:30 (in 2d)" {
|
||||||
|
t.Errorf("FormatDateWithRelative = %q, want %q", result, "2026-02-20 15:30 (in 2d)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecordUndo_And_PopUndo_Add(t *testing.T) {
|
||||||
|
// Create a task, record undo, then pop undo — should hard-delete the task
|
||||||
|
task, err := CreateTask("Undo add test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTask: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := RecordUndo("add", task.UUID); err != nil {
|
||||||
|
t.Fatalf("RecordUndo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
desc, err := PopUndo()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PopUndo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(desc, "Undo") || !strings.Contains(desc, "add") {
|
||||||
|
t.Errorf("unexpected undo description: %s", desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task should be gone
|
||||||
|
_, err = GetTask(task.UUID)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("task should have been hard-deleted after undo add")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordUndo_And_PopUndo_Done(t *testing.T) {
|
||||||
|
task, err := CreateTask("Undo done test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTask: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { task.Delete(true) }()
|
||||||
|
|
||||||
|
// Record undo for the initial creation (so we have a prior change_log entry)
|
||||||
|
// Note: the change_log trigger auto-records on creation, so we just need to
|
||||||
|
// complete and record undo for the completion.
|
||||||
|
|
||||||
|
// Complete the task
|
||||||
|
task.Status = StatusCompleted
|
||||||
|
now := timeNow()
|
||||||
|
task.End = &now
|
||||||
|
if err := task.Save(); err != nil {
|
||||||
|
t.Fatalf("Save completed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := RecordUndo("done", task.UUID); err != nil {
|
||||||
|
t.Fatalf("RecordUndo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo should restore to pending
|
||||||
|
desc, err := PopUndo()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PopUndo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(desc, "done") {
|
||||||
|
t.Errorf("expected 'done' in description: %s", desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload and check status
|
||||||
|
reloaded, err := GetTask(task.UUID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTask after undo: %v", err)
|
||||||
|
}
|
||||||
|
if reloaded.Status != StatusPending {
|
||||||
|
t.Errorf("status after undo done = %d, want %d (pending)", reloaded.Status, StatusPending)
|
||||||
|
}
|
||||||
|
if reloaded.End != nil {
|
||||||
|
t.Error("End should be nil after undo done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordUndo_And_PopUndo_Modify(t *testing.T) {
|
||||||
|
task, err := CreateTask("Undo modify test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTask: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { task.Delete(true) }()
|
||||||
|
|
||||||
|
// Modify the task
|
||||||
|
task.Description = "Modified description"
|
||||||
|
task.Priority = PriorityHigh
|
||||||
|
if err := task.Save(); err != nil {
|
||||||
|
t.Fatalf("Save modified: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := RecordUndo("modify", task.UUID); err != nil {
|
||||||
|
t.Fatalf("RecordUndo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo should restore original description and priority
|
||||||
|
_, err = PopUndo()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PopUndo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reloaded, err := GetTask(task.UUID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTask after undo: %v", err)
|
||||||
|
}
|
||||||
|
if reloaded.Description != "Undo modify test" {
|
||||||
|
t.Errorf("description after undo = %q, want %q", reloaded.Description, "Undo modify test")
|
||||||
|
}
|
||||||
|
if reloaded.Priority != PriorityDefault {
|
||||||
|
t.Errorf("priority after undo = %d, want %d (default)", reloaded.Priority, PriorityDefault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPopUndo_EmptyStack(t *testing.T) {
|
||||||
|
// Clear the undo stack
|
||||||
|
db := GetDB()
|
||||||
|
db.Exec("DELETE FROM undo_stack")
|
||||||
|
|
||||||
|
_, err := PopUndo()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("PopUndo on empty stack should return error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "nothing to undo") {
|
||||||
|
t.Errorf("expected 'nothing to undo' error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUndoStackEviction(t *testing.T) {
|
||||||
|
// Clear existing undo entries
|
||||||
|
db := GetDB()
|
||||||
|
db.Exec("DELETE FROM undo_stack")
|
||||||
|
|
||||||
|
// Create 12 tasks and record undo for each
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
task, err := CreateTask("Eviction test task")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTask %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if err := RecordUndo("add", task.UUID); err != nil {
|
||||||
|
t.Fatalf("RecordUndo %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stack should be capped at 10
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRow("SELECT COUNT(*) FROM undo_stack").Scan(&count); err != nil {
|
||||||
|
t.Fatalf("count query: %v", err)
|
||||||
|
}
|
||||||
|
if count != 10 {
|
||||||
|
t.Errorf("undo stack count = %d, want 10 (limit)", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyChangeLogData(t *testing.T) {
|
||||||
|
task, err := CreateTask("Apply changelog test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTask: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { task.Delete(true) }()
|
||||||
|
|
||||||
|
// Apply changelog data that sets various fields
|
||||||
|
due := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
data := "description: Changed description\nstatus: completed\npriority: H\nproject: work\ndue: " +
|
||||||
|
strings.TrimSpace(time.Unix(due.Unix(), 0).Format("")) + "\n"
|
||||||
|
|
||||||
|
// Construct proper data string
|
||||||
|
data = "description: Changed description\nstatus: completed\npriority: H\nproject: work"
|
||||||
|
|
||||||
|
if err := applyChangeLogData(task, data); err != nil {
|
||||||
|
t.Fatalf("applyChangeLogData: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.Description != "Changed description" {
|
||||||
|
t.Errorf("description = %q, want %q", task.Description, "Changed description")
|
||||||
|
}
|
||||||
|
if task.Status != StatusCompleted {
|
||||||
|
t.Errorf("status = %d, want %d", task.Status, StatusCompleted)
|
||||||
|
}
|
||||||
|
if task.Priority != PriorityHigh {
|
||||||
|
t.Errorf("priority = %d, want %d", task.Priority, PriorityHigh)
|
||||||
|
}
|
||||||
|
if task.Project == nil || *task.Project != "work" {
|
||||||
|
t.Error("project should be 'work'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyChangeLogData_ClearsAbsentFields(t *testing.T) {
|
||||||
|
task, err := CreateTask("Clear fields test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTask: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { task.Delete(true) }()
|
||||||
|
|
||||||
|
// Set some fields first
|
||||||
|
proj := "work"
|
||||||
|
task.Project = &proj
|
||||||
|
now := timeNow()
|
||||||
|
task.Due = &now
|
||||||
|
task.Start = &now
|
||||||
|
|
||||||
|
// Apply data without project, due, or start — they should be cleared
|
||||||
|
data := "description: Clear fields test\nstatus: pending"
|
||||||
|
if err := applyChangeLogData(task, data); err != nil {
|
||||||
|
t.Fatalf("applyChangeLogData: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.Project != nil {
|
||||||
|
t.Error("project should be nil after applying data without project")
|
||||||
|
}
|
||||||
|
if task.Due != nil {
|
||||||
|
t.Error("due should be nil after applying data without due")
|
||||||
|
}
|
||||||
|
if task.Start != nil {
|
||||||
|
t.Error("start should be nil after applying data without start")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileTagsFromChangeLog(t *testing.T) {
|
||||||
|
task, err := CreateTask("Tag reconcile test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTask: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { task.Delete(true) }()
|
||||||
|
|
||||||
|
// Set current tags
|
||||||
|
task.AddTag("keep")
|
||||||
|
task.AddTag("remove")
|
||||||
|
|
||||||
|
// Reconcile with data that has "keep" and "add" but not "remove"
|
||||||
|
data := "tags: keep,add"
|
||||||
|
if err := reconcileTagsFromChangeLog(task, data); err != nil {
|
||||||
|
t.Fatalf("reconcileTagsFromChangeLog: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, _ := task.GetTags()
|
||||||
|
tagSet := make(map[string]bool)
|
||||||
|
for _, tag := range tags {
|
||||||
|
tagSet[tag] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tagSet["keep"] {
|
||||||
|
t.Error("tag 'keep' should still be present")
|
||||||
|
}
|
||||||
|
if !tagSet["add"] {
|
||||||
|
t.Error("tag 'add' should have been added")
|
||||||
|
}
|
||||||
|
if tagSet["remove"] {
|
||||||
|
t.Error("tag 'remove' should have been removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileTagsFromChangeLog_NoTags(t *testing.T) {
|
||||||
|
task, err := CreateTask("No tags reconcile test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTask: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { task.Delete(true) }()
|
||||||
|
|
||||||
|
task.AddTag("should-be-removed")
|
||||||
|
|
||||||
|
// Data with no tags line — all tags should be removed
|
||||||
|
data := "description: No tags reconcile test\nstatus: pending"
|
||||||
|
if err := reconcileTagsFromChangeLog(task, data); err != nil {
|
||||||
|
t.Fatalf("reconcileTagsFromChangeLog: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tags, _ := task.GetTags()
|
||||||
|
if len(tags) != 0 {
|
||||||
|
t.Errorf("expected 0 tags after reconcile with no tags, got %v", tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user