Implement opal-task Phase 5: Recurrence Implementation
- Complete SpawnNextInstance() for creating recurring task instances - Implement automatic next instance spawning on task completion - Add support for 'until' date to expire recurrences - Copy tags from template to new instances - Add comprehensive recurrence tests (6 tests, all passing) - Test pattern parsing, formatting, next due calculation - Test end-to-end recurring task workflow - Test expiration with until dates
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
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
|
||||
if task.Due == nil {
|
||||
t.Error("New instance should have due date")
|
||||
} else {
|
||||
expectedDue := CalculateNextDue(*instance1.Due, duration)
|
||||
// Allow 1 second tolerance due to Unix timestamp precision
|
||||
diff := task.Due.Sub(expectedDue)
|
||||
if diff < -time.Second || diff > time.Second {
|
||||
t.Errorf("Expected due date %v, got %v (diff: %v)", expectedDue, *task.Due, diff)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user