package engine import ( "strings" "testing" "time" ) func TestRecordUndo_And_PopUndo_Add(t *testing.T) { // Create a task, record undo, then pop undo — should hard-delete the task task, err := CreateTask("Undo add test") if err != nil { t.Fatalf("CreateTask: %v", err) } if err := RecordUndo("add", task.UUID); err != nil { t.Fatalf("RecordUndo: %v", err) } desc, err := PopUndo() if err != nil { t.Fatalf("PopUndo: %v", err) } if !strings.Contains(desc, "Undo") || !strings.Contains(desc, "add") { t.Errorf("unexpected undo description: %s", desc) } // Task should be gone _, err = GetTask(task.UUID) if err == nil { t.Error("task should have been hard-deleted after undo add") } } func TestRecordUndo_And_PopUndo_Done(t *testing.T) { task, err := CreateTask("Undo done test") if err != nil { t.Fatalf("CreateTask: %v", err) } defer func() { task.Delete(true) }() // Record undo for the initial creation (so we have a prior change_log entry) // Note: the change_log trigger auto-records on creation, so we just need to // complete and record undo for the completion. // Complete the task task.Status = StatusCompleted now := timeNow() task.End = &now if err := task.Save(); err != nil { t.Fatalf("Save completed: %v", err) } if err := RecordUndo("done", task.UUID); err != nil { t.Fatalf("RecordUndo: %v", err) } // Undo should restore to pending desc, err := PopUndo() if err != nil { t.Fatalf("PopUndo: %v", err) } if !strings.Contains(desc, "done") { t.Errorf("expected 'done' in description: %s", desc) } // Reload and check status reloaded, err := GetTask(task.UUID) if err != nil { t.Fatalf("GetTask after undo: %v", err) } if reloaded.Status != StatusPending { t.Errorf("status after undo done = %d, want %d (pending)", reloaded.Status, StatusPending) } if reloaded.End != nil { t.Error("End should be nil after undo done") } } func TestRecordUndo_And_PopUndo_Modify(t *testing.T) { task, err := CreateTask("Undo modify test") if err != nil { t.Fatalf("CreateTask: %v", err) } defer func() { task.Delete(true) }() // Modify the task task.Description = "Modified description" task.Priority = PriorityHigh if err := task.Save(); err != nil { t.Fatalf("Save modified: %v", err) } if err := RecordUndo("modify", task.UUID); err != nil { t.Fatalf("RecordUndo: %v", err) } // Undo should restore original description and priority _, err = PopUndo() if err != nil { t.Fatalf("PopUndo: %v", err) } reloaded, err := GetTask(task.UUID) if err != nil { t.Fatalf("GetTask after undo: %v", err) } if reloaded.Description != "Undo modify test" { t.Errorf("description after undo = %q, want %q", reloaded.Description, "Undo modify test") } if reloaded.Priority != PriorityDefault { t.Errorf("priority after undo = %d, want %d (default)", reloaded.Priority, PriorityDefault) } } func TestPopUndo_EmptyStack(t *testing.T) { // Clear the undo stack db := GetDB() db.Exec("DELETE FROM undo_stack") _, err := PopUndo() if err == nil { t.Error("PopUndo on empty stack should return error") } if !strings.Contains(err.Error(), "nothing to undo") { t.Errorf("expected 'nothing to undo' error, got: %v", err) } } func TestUndoStackEviction(t *testing.T) { // Clear existing undo entries db := GetDB() db.Exec("DELETE FROM undo_stack") // Create 12 tasks and record undo for each for i := 0; i < 12; i++ { task, err := CreateTask("Eviction test task") if err != nil { t.Fatalf("CreateTask %d: %v", i, err) } if err := RecordUndo("add", task.UUID); err != nil { t.Fatalf("RecordUndo %d: %v", i, err) } } // Stack should be capped at 10 var count int if err := db.QueryRow("SELECT COUNT(*) FROM undo_stack").Scan(&count); err != nil { t.Fatalf("count query: %v", err) } if count != 10 { t.Errorf("undo stack count = %d, want 10 (limit)", count) } } func TestApplyChangeLogData(t *testing.T) { task, err := CreateTask("Apply changelog test") if err != nil { t.Fatalf("CreateTask: %v", err) } defer func() { task.Delete(true) }() // Apply changelog data that sets various fields due := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) data := "description: Changed description\nstatus: completed\npriority: H\nproject: work\ndue: " + strings.TrimSpace(time.Unix(due.Unix(), 0).Format("")) + "\n" // Construct proper data string data = "description: Changed description\nstatus: completed\npriority: H\nproject: work" if err := applyChangeLogData(task, data); err != nil { t.Fatalf("applyChangeLogData: %v", err) } if task.Description != "Changed description" { t.Errorf("description = %q, want %q", task.Description, "Changed description") } if task.Status != StatusCompleted { t.Errorf("status = %d, want %d", task.Status, StatusCompleted) } if task.Priority != PriorityHigh { t.Errorf("priority = %d, want %d", task.Priority, PriorityHigh) } if task.Project == nil || *task.Project != "work" { t.Error("project should be 'work'") } } func TestApplyChangeLogData_ClearsAbsentFields(t *testing.T) { task, err := CreateTask("Clear fields test") if err != nil { t.Fatalf("CreateTask: %v", err) } defer func() { task.Delete(true) }() // Set some fields first proj := "work" task.Project = &proj now := timeNow() task.Due = &now task.Start = &now // Apply data without project, due, or start — they should be cleared data := "description: Clear fields test\nstatus: pending" if err := applyChangeLogData(task, data); err != nil { t.Fatalf("applyChangeLogData: %v", err) } if task.Project != nil { t.Error("project should be nil after applying data without project") } if task.Due != nil { t.Error("due should be nil after applying data without due") } if task.Start != nil { t.Error("start should be nil after applying data without start") } } func TestReconcileTagsFromChangeLog(t *testing.T) { task, err := CreateTask("Tag reconcile test") if err != nil { t.Fatalf("CreateTask: %v", err) } defer func() { task.Delete(true) }() // Set current tags task.AddTag("keep") task.AddTag("remove") // Reconcile with data that has "keep" and "add" but not "remove" data := "tags: keep,add" if err := reconcileTagsFromChangeLog(task, data); err != nil { t.Fatalf("reconcileTagsFromChangeLog: %v", err) } tags, _ := task.GetTags() tagSet := make(map[string]bool) for _, tag := range tags { tagSet[tag] = true } if !tagSet["keep"] { t.Error("tag 'keep' should still be present") } if !tagSet["add"] { t.Error("tag 'add' should have been added") } if tagSet["remove"] { t.Error("tag 'remove' should have been removed") } } func TestReconcileTagsFromChangeLog_NoTags(t *testing.T) { task, err := CreateTask("No tags reconcile test") if err != nil { t.Fatalf("CreateTask: %v", err) } defer func() { task.Delete(true) }() task.AddTag("should-be-removed") // Data with no tags line — all tags should be removed data := "description: No tags reconcile test\nstatus: pending" if err := reconcileTagsFromChangeLog(task, data); err != nil { t.Fatalf("reconcileTagsFromChangeLog: %v", err) } tags, _ := task.GetTags() if len(tags) != 0 { t.Errorf("expected 0 tags after reconcile with no tags, got %v", tags) } }