package handlers import ( "context" "testing" "time" "task-dashboard/internal/config" "task-dashboard/internal/models" ) // Chain steps carry no due date and are kept out of the undated list, so they // stay off the day's timeline entirely until deliberately committed. This is the // whole point of the pull model: the day contains what was chosen plus real // deadlines, so finishing at zero is reachable. func TestBuildTimeline_UnlockedChainTaskAbsentUntilCommitted(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() chain, err := s.CreateChain("Ham Radio Track", []models.ChainTaskInput{ {Content: "Study Technician"}, {Content: "Pass exam"}, {Content: "Study General"}, }) if err != nil { t.Fatal(err) } tasks, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } now := time.Now() items, err := BuildTimeline(context.Background(), s, now.Add(-time.Hour), now.Add(24*time.Hour)) if err != nil { t.Fatalf("BuildTimeline: %v", err) } for _, item := range items { if item.ID == tasks[0].ID { t.Error("uncommitted chain step appeared on the timeline; chain work should only arrive by being committed") } } } func TestBuildTimeline_CommittedChainTaskAppearsWithBadge(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() chain, err := s.CreateChain("Ham Radio Track", []models.ChainTaskInput{ {Content: "Study Technician"}, {Content: "Pass exam"}, {Content: "Study General"}, }) if err != nil { t.Fatal(err) } tasks, err := s.GetChainTasks(chain.ID) if err != nil { t.Fatal(err) } if err := s.SetTaskCommitted(tasks[0].ID, config.Now()); err != nil { t.Fatal(err) } now := time.Now() items, err := BuildTimeline(context.Background(), s, now.Add(-time.Hour), now.Add(24*time.Hour)) if err != nil { t.Fatalf("BuildTimeline: %v", err) } var found bool for _, item := range items { if item.ID == tasks[0].ID { found = true if item.ChainPosition != 1 || item.ChainTotal != 3 { t.Errorf("ChainPosition/ChainTotal = %d/%d, want 1/3", item.ChainPosition, item.ChainTotal) } } if item.ID == tasks[1].ID || item.ID == tasks[2].ID { t.Errorf("locked chain task %q should not appear in the timeline", item.ID) } } if !found { t.Fatal("expected the committed chain step to appear on the timeline") } } // A task that is both committed and due today must appear once, not twice. func TestBuildTimeline_CommittedAndDueTaskNotDuplicated(t *testing.T) { s, cleanup := setupTestDB(t) defer cleanup() due := config.Now() if err := s.CreateNativeTask(models.Task{ID: "dual", Content: "Bills", DueDate: &due}); err != nil { t.Fatal(err) } if err := s.SetTaskCommitted("dual", config.Now()); err != nil { t.Fatal(err) } now := time.Now() items, err := BuildTimeline(context.Background(), s, now.Add(-24*time.Hour), now.Add(24*time.Hour)) if err != nil { t.Fatalf("BuildTimeline: %v", err) } var count int for _, item := range items { if item.ID == "dual" { count++ } } if count != 1 { t.Errorf("task appeared %d times, want exactly 1 (committed and due are two reasons for the same row)", count) } }