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:
2026-01-04 18:13:32 +01:00
parent 9704731739
commit cb4b7ac14b
2 changed files with 377 additions and 3 deletions
@@ -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)
}
}