b02c40f716
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>
327 lines
8.5 KiB
Go
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)
|
|
}
|
|
}
|