Files
gems/opal-task/internal/engine/recurrence_test.go
T
joakim d0b46beeec Add support for daily, weekly, monthly, yearly recurrence patterns
Previously, only numeric forms (1d, 1w, 1m, 1y) and plural word forms
(days, weeks, months, years) were supported for recurrence patterns.
The common adverbial forms (daily, weekly, monthly, yearly) would fail
with 'invalid recurrence pattern' error.

Now users can use natural language recurrence patterns:
- recur:daily   -> 1 day interval
- recur:weekly  -> 7 day interval
- recur:monthly -> 30 day interval
- recur:yearly  -> 365 day interval

Added test coverage for all four new patterns.
2026-01-05 10:36:21 +01:00

327 lines
8.4 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)
}
}