cb4b7ac14b
- 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
318 lines
8.1 KiB
Go
318 lines
8.1 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
|
|
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)
|
|
}
|
|
}
|