a68d701d14
Issue 1: Fix recurrence calculation for overdue tasks - Use completion date (End) as base for next instance, not original due date - If task due Monday completed Wednesday, next is Wednesday+7d not Monday+7d - Fallback to Due date if End is not set - Update test to verify new behavior Issue 2: Fix description parsing to work without quotes - Add parseAddArgs() to extract description from non-modifier words - Description = all words that don't start with +, -, or contain : - Enables: opal add buy groceries +shop carrots → 'buy groceries carrots' - Validate description is required (error if only modifiers) - Validate recurring tasks require due date Issue 3: Implement flexible command syntax - Add preprocessArgs() to parse arguments before Cobra routing - Detect command position and split filters (left) from modifiers (right) - Rewrite os.Args so Cobra routes correctly - Enable both 'opal 2 done' and 'opal done 2' syntax - Commands without modifiers accept filters on either side - Commands with modifiers enforce [filters] command [modifiers] - Add confirmation for modify without filters (modifies all tasks) All commands updated to use preprocessed ParsedArgs from context. All tests passing (33 tests).
322 lines
8.3 KiB
Go
322 lines
8.3 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},
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|