Files
gems/opal-task/internal/engine/recurrence_test.go
T
joakim a68d701d14 Fix three critical UX issues in opal-task
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).
2026-01-04 21:24:14 +01:00

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)
}
}