diff --git a/opal-task/internal/engine/annotate_test.go b/opal-task/internal/engine/annotate_test.go new file mode 100644 index 0000000..0e525f3 --- /dev/null +++ b/opal-task/internal/engine/annotate_test.go @@ -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") + } +} diff --git a/opal-task/internal/engine/dateparse.go b/opal-task/internal/engine/dateparse.go index ec9d63a..14a4e74 100644 --- a/opal-task/internal/engine/dateparse.go +++ b/opal-task/internal/engine/dateparse.go @@ -54,7 +54,7 @@ func (p *DateParser) parseDateOnly(s string) (time.Time, error) { } // 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 } diff --git a/opal-task/internal/engine/dateparse_test.go b/opal-task/internal/engine/dateparse_test.go index 80d8f6c..b764790 100644 --- a/opal-task/internal/engine/dateparse_test.go +++ b/opal-task/internal/engine/dateparse_test.go @@ -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) { tests := []struct { name string diff --git a/opal-task/internal/engine/history_test.go b/opal-task/internal/engine/history_test.go new file mode 100644 index 0000000..74d01f4 --- /dev/null +++ b/opal-task/internal/engine/history_test.go @@ -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) + } + }) + } +} diff --git a/opal-task/internal/engine/reldate_test.go b/opal-task/internal/engine/reldate_test.go new file mode 100644 index 0000000..0911268 --- /dev/null +++ b/opal-task/internal/engine/reldate_test.go @@ -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)") + } +} diff --git a/opal-task/internal/engine/undo_test.go b/opal-task/internal/engine/undo_test.go new file mode 100644 index 0000000..9f8a70d --- /dev/null +++ b/opal-task/internal/engine/undo_test.go @@ -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) + } +}