Files
joakim b02c40f716 feat: improve CLI output with relative dates, rich feedback, and recurring task info
Add relative date formatting (today, tomorrow, in 3d, etc.) for list and
detail views. Add structured feedback helpers for add/complete/delete
operations showing display IDs and parsed modifiers. Change Complete() to
return spawned recurring instance so callers can display recurrence info.
Add AppendTask to working set for immediate display ID assignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:44:56 +01:00

327 lines
8.5 KiB
Go

package engine
import (
"testing"
"time"
"github.com/google/uuid"
)
func TestParseRecurrencePattern(t *testing.T) {
tests := []struct {
pattern string
expected time.Duration
}{
{"1d", 24 * time.Hour},
{"7d", 7 * 24 * time.Hour},
{"1w", 7 * 24 * time.Hour},
{"2w", 14 * 24 * time.Hour},
{"1m", 30 * 24 * time.Hour},
{"1y", 365 * 24 * time.Hour},
// Test word forms
{"daily", 24 * time.Hour},
{"weekly", 7 * 24 * time.Hour},
{"monthly", 30 * 24 * time.Hour},
{"yearly", 365 * 24 * time.Hour},
}
for _, tt := range tests {
t.Run(tt.pattern, func(t *testing.T) {
result, err := ParseRecurrencePattern(tt.pattern)
if err != nil {
t.Fatalf("Failed to parse pattern %s: %v", tt.pattern, err)
}
if result != tt.expected {
t.Errorf("Pattern %s: expected %v, got %v", tt.pattern, tt.expected, result)
}
})
}
}
func TestFormatRecurrenceDuration(t *testing.T) {
tests := []struct {
duration time.Duration
expected string
}{
{24 * time.Hour, "1d"},
{7 * 24 * time.Hour, "1w"},
{14 * 24 * time.Hour, "2w"},
{30 * 24 * time.Hour, "1m"},
{365 * 24 * time.Hour, "1y"},
}
for _, tt := range tests {
t.Run(tt.expected, func(t *testing.T) {
result := FormatRecurrenceDuration(tt.duration)
if result != tt.expected {
t.Errorf("Duration %v: expected %s, got %s", tt.duration, tt.expected, result)
}
})
}
}
func TestCalculateNextDue(t *testing.T) {
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
// Test weekly recurrence
next := CalculateNextDue(base, 7*24*time.Hour)
expected := time.Date(2026, 1, 8, 0, 0, 0, 0, time.UTC)
if !next.Equal(expected) {
t.Errorf("Expected %v, got %v", expected, next)
}
// Test daily recurrence
next2 := CalculateNextDue(base, 24*time.Hour)
expected2 := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
if !next2.Equal(expected2) {
t.Errorf("Expected %v, got %v", expected2, next2)
}
}
func TestRecurringTaskCreation(t *testing.T) {
// Create a recurring task (template + first instance)
dueDate := time.Now().Add(24 * time.Hour)
duration := 7 * 24 * time.Hour
// Create template
template := &Task{
UUID: uuid.New(),
Status: StatusRecurring,
Description: "Weekly review",
Priority: PriorityMedium,
Created: time.Now(),
Modified: time.Now(),
Due: &dueDate,
RecurrenceDuration: &duration,
ParentUUID: nil, // This is the template
Tags: []string{},
}
if err := template.Save(); err != nil {
t.Fatalf("Failed to save template: %v", err)
}
if err := template.AddTag("recurring"); err != nil {
t.Fatalf("Failed to add tag to template: %v", err)
}
// Create first instance
instance := &Task{
UUID: uuid.New(),
Status: StatusPending,
Description: template.Description,
Priority: template.Priority,
Created: time.Now(),
Modified: time.Now(),
Due: template.Due,
ParentUUID: &template.UUID,
Tags: []string{},
}
if err := instance.Save(); err != nil {
t.Fatalf("Failed to save instance: %v", err)
}
if err := instance.AddTag("recurring"); err != nil {
t.Fatalf("Failed to add tag to instance: %v", err)
}
// Verify instance is linked to template
if instance.ParentUUID == nil {
t.Error("Instance should have parent UUID")
}
if *instance.ParentUUID != template.UUID {
t.Error("Instance parent UUID should match template UUID")
}
// Verify template is recurring
if !template.IsRecurringTemplate() {
t.Error("Template should be identified as recurring template")
}
// Verify instance is a recurring instance
if !instance.IsRecurringInstance() {
t.Error("Instance should be identified as recurring instance")
}
}
func TestSpawnNextInstance(t *testing.T) {
// Create template
dueDate := time.Now().Add(24 * time.Hour)
duration := 7 * 24 * time.Hour
template := &Task{
UUID: uuid.New(),
Status: StatusRecurring,
Description: "Weekly task",
Priority: PriorityHigh,
Created: time.Now(),
Modified: time.Now(),
Due: &dueDate,
RecurrenceDuration: &duration,
ParentUUID: nil,
Tags: []string{},
}
if err := template.Save(); err != nil {
t.Fatalf("Failed to save template: %v", err)
}
if err := template.AddTag("work"); err != nil {
t.Fatalf("Failed to add tag: %v", err)
}
// Create and complete first instance
instance1 := &Task{
UUID: uuid.New(),
Status: StatusPending,
Description: template.Description,
Priority: template.Priority,
Created: time.Now(),
Modified: time.Now(),
Due: template.Due,
ParentUUID: &template.UUID,
Tags: []string{},
}
if err := instance1.Save(); err != nil {
t.Fatalf("Failed to save instance: %v", err)
}
if err := instance1.AddTag("work"); err != nil {
t.Fatalf("Failed to add tag to instance: %v", err)
}
// Complete the instance (should spawn next)
if _, err := instance1.Complete(); err != nil {
t.Fatalf("Failed to complete instance: %v", err)
}
// Find the newly spawned instance
filter := DefaultFilter()
tasks, err := GetTasks(filter)
if err != nil {
t.Fatalf("Failed to get tasks: %v", err)
}
// Should have at least one new pending instance
found := false
for _, task := range tasks {
if task.ParentUUID != nil && *task.ParentUUID == template.UUID && task.Status == StatusPending {
found = true
// Verify due date was advanced
// New behavior: calculates from End date (completion time), not original due date
if task.Due == nil {
t.Error("New instance should have due date")
} else {
// Should be 7 days from when we completed instance1
// Allow reasonable tolerance for test timing
diff := task.Due.Sub(time.Now())
expectedDiff := duration
tolerance := 5 * time.Second
if diff < expectedDiff-tolerance || diff > expectedDiff+tolerance {
t.Errorf("Expected due date ~%v from now, got %v from now (diff: %v)", duration, diff, diff-expectedDiff)
}
}
// Verify tags were copied
if len(task.Tags) == 0 {
t.Error("Tags should be copied to new instance")
}
foundTag := false
for _, tag := range task.Tags {
if tag == "work" {
foundTag = true
}
}
if !foundTag {
t.Error("'work' tag should be copied to new instance")
}
break
}
}
if !found {
t.Error("Should have spawned a new instance after completing recurring task")
}
}
func TestRecurrenceWithUntilDate(t *testing.T) {
// Create template with until date very soon
dueDate := time.Now().Add(-24 * time.Hour) // Already past
until := time.Now().Add(-12 * time.Hour) // Until date also past
duration := 7 * 24 * time.Hour
template := &Task{
UUID: uuid.New(),
Status: StatusRecurring,
Description: "Expired recurring task",
Priority: PriorityMedium,
Created: time.Now(),
Modified: time.Now(),
Due: &dueDate,
Until: &until,
RecurrenceDuration: &duration,
ParentUUID: nil,
Tags: []string{},
}
if err := template.Save(); err != nil {
t.Fatalf("Failed to save template: %v", err)
}
// Create instance
instance := &Task{
UUID: uuid.New(),
Status: StatusPending,
Description: template.Description,
Priority: template.Priority,
Created: time.Now(),
Modified: time.Now(),
Due: template.Due,
Until: template.Until,
ParentUUID: &template.UUID,
Tags: []string{},
}
if err := instance.Save(); err != nil {
t.Fatalf("Failed to save instance: %v", err)
}
// Count pending instances before
filter := DefaultFilter()
beforeTasks, _ := GetTasks(filter)
beforeCount := 0
for _, task := range beforeTasks {
if task.ParentUUID != nil && *task.ParentUUID == template.UUID && task.Status == StatusPending {
beforeCount++
}
}
// Complete instance - should NOT spawn new one (past until date)
if _, err := instance.Complete(); err != nil {
t.Fatalf("Failed to complete instance: %v", err)
}
// Count pending instances after
afterTasks, _ := GetTasks(filter)
afterCount := 0
for _, task := range afterTasks {
if task.ParentUUID != nil && *task.ParentUUID == template.UUID && task.Status == StatusPending {
afterCount++
}
}
// Should have one less (the one we completed) and no new one spawned
if afterCount != beforeCount-1 {
t.Errorf("Expected %d pending instances after, got %d (should not spawn due to until date)", beforeCount-1, afterCount)
}
}