Files
honeyDueAPI/internal/task/categorization/chain_test.go
Trey t 4976eafc6c Rebrand from Casera/MyCrib to honeyDue
Total rebrand across all Go API source files:
- Go module path: casera-api -> honeydue-api
- All imports updated (130+ files)
- Docker: containers, images, networks renamed
- Email templates: support email, noreply, icon URL
- Domains: casera.app/mycrib.treytartt.com -> honeyDue.treytartt.com
- Bundle IDs: com.tt.casera -> com.tt.honeyDue
- IAP product IDs updated
- Landing page, admin panel, config defaults
- Seeds, CI workflows, Makefile, docs
- Database table names preserved (no migration needed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 06:33:38 -06:00

804 lines
28 KiB
Go

package categorization_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/task/categorization"
)
// Ensure assert is used (referenced in fuzz/property tests below)
var _ = assert.Equal
// Helper to create a time pointer
func timePtr(t time.Time) *time.Time {
return &t
}
func TestCategorizeTask_PriorityOrder(t *testing.T) {
now := time.Now().UTC()
yesterday := now.AddDate(0, 0, -1)
in5Days := now.AddDate(0, 0, 5)
in60Days := now.AddDate(0, 0, 60)
daysThreshold := 30
tests := []struct {
name string
task *models.Task
expected categorization.KanbanColumn
}{
// Priority 1: Cancelled
{
name: "cancelled takes priority over everything",
task: &models.Task{
IsCancelled: true,
NextDueDate: timePtr(yesterday), // Would be overdue
InProgress: true, // Would be in progress
Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, // Would be completed if NextDueDate was nil
},
expected: categorization.ColumnCancelled,
},
// Priority 2: Completed
{
name: "completed: NextDueDate nil with completions",
task: &models.Task{
IsCancelled: false,
IsArchived: false,
NextDueDate: nil,
DueDate: timePtr(yesterday), // Would be overdue if not completed
Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}},
},
expected: categorization.ColumnCompleted,
},
{
name: "not completed when NextDueDate set (recurring task with completions)",
task: &models.Task{
IsCancelled: false,
IsArchived: false,
NextDueDate: timePtr(in5Days),
Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}},
},
expected: categorization.ColumnDueSoon, // Falls through to due soon
},
// Priority 3: In Progress
{
name: "in progress takes priority over overdue",
task: &models.Task{
IsCancelled: false,
IsArchived: false,
NextDueDate: timePtr(yesterday), // Would be overdue
InProgress: true,
Completions: []models.TaskCompletion{},
},
expected: categorization.ColumnInProgress,
},
// Priority 4: Overdue
{
name: "overdue: effective date in past",
task: &models.Task{
IsCancelled: false,
IsArchived: false,
NextDueDate: timePtr(yesterday),
Completions: []models.TaskCompletion{},
},
expected: categorization.ColumnOverdue,
},
{
name: "overdue: uses DueDate when NextDueDate nil (no completions)",
task: &models.Task{
IsCancelled: false,
IsArchived: false,
NextDueDate: nil,
DueDate: timePtr(yesterday),
Completions: []models.TaskCompletion{},
},
expected: categorization.ColumnOverdue,
},
// Priority 5: Due Soon
{
name: "due soon: within threshold",
task: &models.Task{
IsCancelled: false,
IsArchived: false,
NextDueDate: timePtr(in5Days),
Completions: []models.TaskCompletion{},
},
expected: categorization.ColumnDueSoon,
},
// Priority 6: Upcoming (default)
{
name: "upcoming: beyond threshold",
task: &models.Task{
IsCancelled: false,
IsArchived: false,
NextDueDate: timePtr(in60Days),
Completions: []models.TaskCompletion{},
},
expected: categorization.ColumnUpcoming,
},
{
name: "upcoming: no due date",
task: &models.Task{
IsCancelled: false,
IsArchived: false,
NextDueDate: nil,
DueDate: nil,
Completions: []models.TaskCompletion{},
},
expected: categorization.ColumnUpcoming,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := categorization.CategorizeTask(tt.task, daysThreshold)
if result != tt.expected {
t.Errorf("CategorizeTask() = %v, expected %v", result, tt.expected)
}
})
}
}
func TestCategorizeTasksIntoColumns(t *testing.T) {
now := time.Now().UTC()
yesterday := now.AddDate(0, 0, -1)
in5Days := now.AddDate(0, 0, 5)
in60Days := now.AddDate(0, 0, 60)
daysThreshold := 30
tasks := []models.Task{
{BaseModel: models.BaseModel{ID: 1}, IsCancelled: true}, // Cancelled
{BaseModel: models.BaseModel{ID: 2}, NextDueDate: nil, Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}}, // Completed
{BaseModel: models.BaseModel{ID: 3}, InProgress: true}, // In Progress
{BaseModel: models.BaseModel{ID: 4}, NextDueDate: timePtr(yesterday)}, // Overdue
{BaseModel: models.BaseModel{ID: 5}, NextDueDate: timePtr(in5Days)}, // Due Soon
{BaseModel: models.BaseModel{ID: 6}, NextDueDate: timePtr(in60Days)}, // Upcoming
{BaseModel: models.BaseModel{ID: 7}}, // Upcoming (no due date)
}
result := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold)
// Check each column has the expected tasks
if len(result[categorization.ColumnCancelled]) != 1 || result[categorization.ColumnCancelled][0].ID != 1 {
t.Errorf("Expected task 1 in Cancelled column")
}
if len(result[categorization.ColumnCompleted]) != 1 || result[categorization.ColumnCompleted][0].ID != 2 {
t.Errorf("Expected task 2 in Completed column")
}
if len(result[categorization.ColumnInProgress]) != 1 || result[categorization.ColumnInProgress][0].ID != 3 {
t.Errorf("Expected task 3 in InProgress column")
}
if len(result[categorization.ColumnOverdue]) != 1 || result[categorization.ColumnOverdue][0].ID != 4 {
t.Errorf("Expected task 4 in Overdue column")
}
if len(result[categorization.ColumnDueSoon]) != 1 || result[categorization.ColumnDueSoon][0].ID != 5 {
t.Errorf("Expected task 5 in DueSoon column")
}
if len(result[categorization.ColumnUpcoming]) != 2 {
t.Errorf("Expected 2 tasks in Upcoming column, got %d", len(result[categorization.ColumnUpcoming]))
}
}
func TestDetermineKanbanColumn_ReturnsString(t *testing.T) {
task := &models.Task{IsCancelled: true}
result := categorization.DetermineKanbanColumn(task, 30)
if result != "cancelled_tasks" {
t.Errorf("DetermineKanbanColumn() = %v, expected %v", result, "cancelled_tasks")
}
}
func TestKanbanColumnConstants(t *testing.T) {
// Verify column string values match expected API values
tests := []struct {
column categorization.KanbanColumn
expected string
}{
{categorization.ColumnOverdue, "overdue_tasks"},
{categorization.ColumnDueSoon, "due_soon_tasks"},
{categorization.ColumnUpcoming, "upcoming_tasks"},
{categorization.ColumnInProgress, "in_progress_tasks"},
{categorization.ColumnCompleted, "completed_tasks"},
{categorization.ColumnCancelled, "cancelled_tasks"},
}
for _, tt := range tests {
if tt.column.String() != tt.expected {
t.Errorf("Column %v.String() = %v, expected %v", tt.column, tt.column.String(), tt.expected)
}
}
}
func TestNewContext_DefaultThreshold(t *testing.T) {
task := &models.Task{}
// Zero threshold should default to 30
ctx := categorization.NewContext(task, 0)
if ctx.DaysThreshold != 30 {
t.Errorf("NewContext with 0 threshold should default to 30, got %d", ctx.DaysThreshold)
}
// Negative threshold should default to 30
ctx = categorization.NewContext(task, -5)
if ctx.DaysThreshold != 30 {
t.Errorf("NewContext with negative threshold should default to 30, got %d", ctx.DaysThreshold)
}
// Positive threshold should be used
ctx = categorization.NewContext(task, 45)
if ctx.DaysThreshold != 45 {
t.Errorf("NewContext with 45 threshold should be 45, got %d", ctx.DaysThreshold)
}
}
// ============================================================================
// TIMEZONE TESTS
// These tests verify that kanban categorization works correctly across timezones.
// The key insight: a task's due date is stored as a date (YYYY-MM-DD), but
// categorization depends on "what day is it NOW" in the user's timezone.
// ============================================================================
func TestTimezone_SameTaskDifferentCategorization(t *testing.T) {
// Scenario: A task due on Dec 17, 2025
// At 11 PM UTC on Dec 16 (still Dec 16 in UTC)
// But 8 AM on Dec 17 in Tokyo (+9 hours)
// The task should be "due_soon" for UTC user but already in "due_soon" for Tokyo
// (not overdue yet for either - both are still on or before Dec 17)
// Task due Dec 17, 2025 (stored as midnight UTC)
taskDueDate := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
task := &models.Task{
NextDueDate: timePtr(taskDueDate),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
}
// User in UTC: It's Dec 16, 2025 at 11 PM UTC
utcTime := time.Date(2025, 12, 16, 23, 0, 0, 0, time.UTC)
// User in Tokyo: Same instant but it's Dec 17, 2025 at 8 AM local
tokyo, _ := time.LoadLocation("Asia/Tokyo")
tokyoTime := utcTime.In(tokyo) // Same instant, different representation
// For UTC user: Dec 17 is tomorrow (1 day away) - should be due_soon
resultUTC := categorization.CategorizeTaskWithTime(task, 30, utcTime)
if resultUTC != categorization.ColumnDueSoon {
t.Errorf("UTC (Dec 16): expected due_soon, got %v", resultUTC)
}
// For Tokyo user: Dec 17 is TODAY - should still be due_soon (not overdue)
resultTokyo := categorization.CategorizeTaskWithTime(task, 30, tokyoTime)
if resultTokyo != categorization.ColumnDueSoon {
t.Errorf("Tokyo (Dec 17): expected due_soon, got %v", resultTokyo)
}
}
func TestTimezone_TaskBecomesOverdue_DifferentTimezones(t *testing.T) {
// Scenario: A task due on Dec 16, 2025
// At 11 PM UTC on Dec 16 (still Dec 16 in UTC) - due_soon
// At 8 AM UTC on Dec 17 - now overdue
// But for Tokyo user at 11 PM UTC (8 AM Dec 17 Tokyo) - already overdue
taskDueDate := time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC)
task := &models.Task{
NextDueDate: timePtr(taskDueDate),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
}
// Case 1: UTC user at 11 PM on Dec 16 - task is due TODAY, so due_soon
utcDec16Evening := time.Date(2025, 12, 16, 23, 0, 0, 0, time.UTC)
resultUTCEvening := categorization.CategorizeTaskWithTime(task, 30, utcDec16Evening)
if resultUTCEvening != categorization.ColumnDueSoon {
t.Errorf("UTC Dec 16 evening: expected due_soon, got %v", resultUTCEvening)
}
// Case 2: UTC user at 8 AM on Dec 17 - task is now OVERDUE
utcDec17Morning := time.Date(2025, 12, 17, 8, 0, 0, 0, time.UTC)
resultUTCMorning := categorization.CategorizeTaskWithTime(task, 30, utcDec17Morning)
if resultUTCMorning != categorization.ColumnOverdue {
t.Errorf("UTC Dec 17 morning: expected overdue, got %v", resultUTCMorning)
}
// Case 3: Tokyo user at the same instant as case 1
// 11 PM UTC = 8 AM Dec 17 in Tokyo
// For Tokyo user, Dec 16 was yesterday, so task is OVERDUE
tokyo, _ := time.LoadLocation("Asia/Tokyo")
tokyoTime := utcDec16Evening.In(tokyo)
resultTokyo := categorization.CategorizeTaskWithTime(task, 30, tokyoTime)
if resultTokyo != categorization.ColumnOverdue {
t.Errorf("Tokyo (same instant as UTC Dec 16 evening): expected overdue, got %v", resultTokyo)
}
}
func TestTimezone_InternationalDateLine(t *testing.T) {
// Test across the international date line
// Auckland (UTC+13) vs Honolulu (UTC-10)
// 23 hour difference!
// Task due Dec 17, 2025
taskDueDate := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
task := &models.Task{
NextDueDate: timePtr(taskDueDate),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
}
// At midnight UTC on Dec 17
utcTime := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
// Auckland: Dec 17 midnight UTC = Dec 17, 1 PM local (UTC+13)
// Task is due today in Auckland - should be due_soon
auckland, _ := time.LoadLocation("Pacific/Auckland")
aucklandTime := utcTime.In(auckland)
resultAuckland := categorization.CategorizeTaskWithTime(task, 30, aucklandTime)
if resultAuckland != categorization.ColumnDueSoon {
t.Errorf("Auckland (Dec 17, 1 PM): expected due_soon, got %v", resultAuckland)
}
// Honolulu: Dec 17 midnight UTC = Dec 16, 2 PM local (UTC-10)
// Task is due tomorrow in Honolulu - should be due_soon
honolulu, _ := time.LoadLocation("Pacific/Honolulu")
honoluluTime := utcTime.In(honolulu)
resultHonolulu := categorization.CategorizeTaskWithTime(task, 30, honoluluTime)
if resultHonolulu != categorization.ColumnDueSoon {
t.Errorf("Honolulu (Dec 16, 2 PM): expected due_soon, got %v", resultHonolulu)
}
// Now advance to Dec 18 midnight UTC
// Auckland: Dec 18, 1 PM local - task due Dec 17 is now OVERDUE
// Honolulu: Dec 17, 2 PM local - task due Dec 17 is TODAY (due_soon)
utcDec18 := time.Date(2025, 12, 18, 0, 0, 0, 0, time.UTC)
aucklandDec18 := utcDec18.In(auckland)
resultAuckland2 := categorization.CategorizeTaskWithTime(task, 30, aucklandDec18)
if resultAuckland2 != categorization.ColumnOverdue {
t.Errorf("Auckland (Dec 18): expected overdue, got %v", resultAuckland2)
}
honoluluDec17 := utcDec18.In(honolulu)
resultHonolulu2 := categorization.CategorizeTaskWithTime(task, 30, honoluluDec17)
if resultHonolulu2 != categorization.ColumnDueSoon {
t.Errorf("Honolulu (Dec 17): expected due_soon, got %v", resultHonolulu2)
}
}
func TestTimezone_DueSoonThreshold_CrossesTimezones(t *testing.T) {
// Test that the 30-day threshold is calculated correctly in different timezones
// Task due 29 days from now (within threshold for both timezones)
// Task due 31 days from now (outside threshold)
now := time.Date(2025, 12, 16, 12, 0, 0, 0, time.UTC)
// Task due in 29 days
due29Days := time.Date(2026, 1, 14, 0, 0, 0, 0, time.UTC)
task29 := &models.Task{
NextDueDate: timePtr(due29Days),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
}
// Task due in 31 days
due31Days := time.Date(2026, 1, 16, 0, 0, 0, 0, time.UTC)
task31 := &models.Task{
NextDueDate: timePtr(due31Days),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
}
// UTC user
result29UTC := categorization.CategorizeTaskWithTime(task29, 30, now)
if result29UTC != categorization.ColumnDueSoon {
t.Errorf("29 days (UTC): expected due_soon, got %v", result29UTC)
}
result31UTC := categorization.CategorizeTaskWithTime(task31, 30, now)
if result31UTC != categorization.ColumnUpcoming {
t.Errorf("31 days (UTC): expected upcoming, got %v", result31UTC)
}
// Tokyo user at same instant
tokyo, _ := time.LoadLocation("Asia/Tokyo")
tokyoNow := now.In(tokyo)
result29Tokyo := categorization.CategorizeTaskWithTime(task29, 30, tokyoNow)
if result29Tokyo != categorization.ColumnDueSoon {
t.Errorf("29 days (Tokyo): expected due_soon, got %v", result29Tokyo)
}
result31Tokyo := categorization.CategorizeTaskWithTime(task31, 30, tokyoNow)
if result31Tokyo != categorization.ColumnUpcoming {
t.Errorf("31 days (Tokyo): expected upcoming, got %v", result31Tokyo)
}
}
func TestTimezone_StartOfDayNormalization(t *testing.T) {
// Test that times are normalized to start of day in the given timezone
// A task due Dec 17
taskDueDate := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
task := &models.Task{
NextDueDate: timePtr(taskDueDate),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
}
// Test that different times on the SAME DAY produce the SAME result
// All of these should evaluate to "Dec 16" (today), making Dec 17 "due_soon"
times := []time.Time{
time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC), // Midnight
time.Date(2025, 12, 16, 6, 0, 0, 0, time.UTC), // 6 AM
time.Date(2025, 12, 16, 12, 0, 0, 0, time.UTC), // Noon
time.Date(2025, 12, 16, 18, 0, 0, 0, time.UTC), // 6 PM
time.Date(2025, 12, 16, 23, 59, 59, 0, time.UTC), // Just before midnight
}
for _, nowTime := range times {
result := categorization.CategorizeTaskWithTime(task, 30, nowTime)
if result != categorization.ColumnDueSoon {
t.Errorf("At %v: expected due_soon, got %v", nowTime.Format("15:04:05"), result)
}
}
}
func TestTimezone_DST_Transitions(t *testing.T) {
// Test behavior during daylight saving time transitions
// Los Angeles transitions from PDT to PST in early November
la, err := time.LoadLocation("America/Los_Angeles")
if err != nil {
t.Skip("America/Los_Angeles timezone not available")
}
// Task due Nov 3, 2025 (DST ends in LA on Nov 2, 2025)
taskDueDate := time.Date(2025, 11, 3, 0, 0, 0, 0, time.UTC)
task := &models.Task{
NextDueDate: timePtr(taskDueDate),
IsCancelled: false,
IsArchived: false,
Completions: []models.TaskCompletion{},
}
// Nov 2 at 11 PM LA time (during DST transition)
// This should still be Nov 2, so Nov 3 is tomorrow (due_soon)
laNov2Late := time.Date(2025, 11, 2, 23, 0, 0, 0, la)
result := categorization.CategorizeTaskWithTime(task, 30, laNov2Late)
if result != categorization.ColumnDueSoon {
t.Errorf("Nov 2 late evening LA: expected due_soon, got %v", result)
}
// Nov 3 at 1 AM LA time (after DST ends)
// This is Nov 3, so task is due today (due_soon)
laNov3Early := time.Date(2025, 11, 3, 1, 0, 0, 0, la)
result = categorization.CategorizeTaskWithTime(task, 30, laNov3Early)
if result != categorization.ColumnDueSoon {
t.Errorf("Nov 3 early morning LA: expected due_soon, got %v", result)
}
// Nov 4 at any time (after due date)
laNov4 := time.Date(2025, 11, 4, 8, 0, 0, 0, la)
result = categorization.CategorizeTaskWithTime(task, 30, laNov4)
if result != categorization.ColumnOverdue {
t.Errorf("Nov 4 LA: expected overdue, got %v", result)
}
}
func TestTimezone_MultipleTasksIntoColumns(t *testing.T) {
// Test CategorizeTasksIntoColumnsWithTime with timezone-aware categorization
// Tasks with various due dates
dec16 := time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC)
dec17 := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
jan15 := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC)
tasks := []models.Task{
{BaseModel: models.BaseModel{ID: 1}, NextDueDate: timePtr(dec16)}, // Due Dec 16
{BaseModel: models.BaseModel{ID: 2}, NextDueDate: timePtr(dec17)}, // Due Dec 17
{BaseModel: models.BaseModel{ID: 3}, NextDueDate: timePtr(jan15)}, // Due Jan 15
}
// Categorize as of Dec 17 midnight UTC
now := time.Date(2025, 12, 17, 0, 0, 0, 0, time.UTC)
result := categorization.CategorizeTasksIntoColumnsWithTime(tasks, 30, now)
// Dec 16 should be overdue (yesterday)
if len(result[categorization.ColumnOverdue]) != 1 || result[categorization.ColumnOverdue][0].ID != 1 {
t.Errorf("Expected task 1 (Dec 16) in overdue column, got %d tasks", len(result[categorization.ColumnOverdue]))
}
// Dec 17 (today) and Jan 15 (29 days away) should both be in due_soon
// Dec 17 to Jan 15 = 29 days (Dec 17-31 = 14 days, Jan 1-15 = 15 days)
dueSoonTasks := result[categorization.ColumnDueSoon]
if len(dueSoonTasks) != 2 {
t.Errorf("Expected 2 tasks in due_soon column, got %d", len(dueSoonTasks))
}
// Verify both task 2 and 3 are in due_soon
foundTask2 := false
foundTask3 := false
for _, task := range dueSoonTasks {
if task.ID == 2 {
foundTask2 = true
}
if task.ID == 3 {
foundTask3 = true
}
}
if !foundTask2 {
t.Errorf("Expected task 2 (Dec 17) in due_soon column")
}
if !foundTask3 {
t.Errorf("Expected task 3 (Jan 15) in due_soon column")
}
}
// ============================================================================
// FUZZ / PROPERTY TESTS
// These tests verify invariants that must hold for ALL possible task states,
// not just specific hand-crafted examples.
//
// validColumns is defined in chain_breakit_test.go and shared across test files
// in the categorization_test package.
// ============================================================================
// FuzzCategorizeTaskExtended feeds random task states into CategorizeTask using
// separate boolean flags for date presence and day-offset integers for date
// values. This complements FuzzCategorizeTask (in chain_breakit_test.go) by
// exercising the nil-date paths more directly.
func FuzzCategorizeTaskExtended(f *testing.F) {
// Seed corpus: cover a representative spread of boolean/date combinations.
// isCancelled, isArchived, inProgress, hasCompletions,
// hasDueDate, dueDateOffsetDays, hasNextDueDate, nextDueDateOffsetDays
f.Add(false, false, false, false, false, 0, false, 0)
f.Add(true, false, false, false, false, 0, false, 0)
f.Add(false, true, false, false, false, 0, false, 0)
f.Add(false, false, true, false, false, 0, false, 0)
f.Add(false, false, false, true, false, 0, false, 0) // completed (no next due, has completions)
f.Add(false, false, false, false, true, -5, false, 0) // overdue via DueDate
f.Add(false, false, false, false, false, 0, true, -5) // overdue via NextDueDate
f.Add(false, false, false, false, false, 0, true, 5) // due soon
f.Add(false, false, false, false, false, 0, true, 60) // upcoming
f.Add(true, true, true, true, true, -10, true, -10) // everything set
f.Add(false, false, false, false, true, 100, true, 100) // far future
f.Fuzz(func(t *testing.T,
isCancelled, isArchived, inProgress, hasCompletions bool,
hasDueDate bool, dueDateOffsetDays int,
hasNextDueDate bool, nextDueDateOffsetDays int,
) {
now := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
task := &models.Task{
IsCancelled: isCancelled,
IsArchived: isArchived,
InProgress: inProgress,
}
if hasDueDate {
d := now.AddDate(0, 0, dueDateOffsetDays)
task.DueDate = &d
}
if hasNextDueDate {
d := now.AddDate(0, 0, nextDueDateOffsetDays)
task.NextDueDate = &d
}
if hasCompletions {
task.Completions = []models.TaskCompletion{
{BaseModel: models.BaseModel{ID: 1}},
}
} else {
task.Completions = []models.TaskCompletion{}
}
result := categorization.CategorizeTask(task, 30)
// Invariant 1: result must never be the empty string.
if result == "" {
t.Fatalf("CategorizeTask returned empty string for task %+v", task)
}
// Invariant 2: result must be one of the valid KanbanColumn constants.
if !validColumns[result] {
t.Fatalf("CategorizeTask returned invalid column %q for task %+v", result, task)
}
})
}
// TestCategorizeTask_MutuallyExclusive exhaustively enumerates all boolean
// state combinations (IsCancelled, IsArchived, InProgress, hasCompletions)
// crossed with representative date positions (no date, past, today, within
// threshold, beyond threshold) and asserts that every task maps to exactly
// one valid, non-empty column.
func TestCategorizeTask_MutuallyExclusive(t *testing.T) {
now := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
daysThreshold := 30
// Date scenarios relative to "now" for both DueDate and NextDueDate.
type dateScenario struct {
name string
dueDate *time.Time
nextDue *time.Time
}
past := now.AddDate(0, 0, -5)
today := now
withinThreshold := now.AddDate(0, 0, 10)
beyondThreshold := now.AddDate(0, 0, 60)
dateScenarios := []dateScenario{
{"no dates", nil, nil},
{"DueDate past only", &past, nil},
{"DueDate today only", &today, nil},
{"DueDate within threshold", &withinThreshold, nil},
{"DueDate beyond threshold", &beyondThreshold, nil},
{"NextDueDate past", nil, &past},
{"NextDueDate today", nil, &today},
{"NextDueDate within threshold", nil, &withinThreshold},
{"NextDueDate beyond threshold", nil, &beyondThreshold},
{"both past", &past, &past},
{"DueDate past NextDueDate future", &past, &withinThreshold},
{"both beyond threshold", &beyondThreshold, &beyondThreshold},
}
boolCombos := []struct {
cancelled, archived, inProgress, hasCompletions bool
}{
{false, false, false, false},
{true, false, false, false},
{false, true, false, false},
{false, false, true, false},
{false, false, false, true},
{true, true, false, false},
{true, false, true, false},
{true, false, false, true},
{false, true, true, false},
{false, true, false, true},
{false, false, true, true},
{true, true, true, false},
{true, true, false, true},
{true, false, true, true},
{false, true, true, true},
{true, true, true, true},
}
for _, ds := range dateScenarios {
for _, bc := range boolCombos {
task := &models.Task{
IsCancelled: bc.cancelled,
IsArchived: bc.archived,
InProgress: bc.inProgress,
DueDate: ds.dueDate,
NextDueDate: ds.nextDue,
}
if bc.hasCompletions {
task.Completions = []models.TaskCompletion{
{BaseModel: models.BaseModel{ID: 1}},
}
} else {
task.Completions = []models.TaskCompletion{}
}
result := categorization.CategorizeTaskWithTime(task, daysThreshold, now)
assert.NotEmpty(t, result,
"empty column for dates=%s cancelled=%v archived=%v inProgress=%v completions=%v",
ds.name, bc.cancelled, bc.archived, bc.inProgress, bc.hasCompletions)
assert.True(t, validColumns[result],
"invalid column %q for dates=%s cancelled=%v archived=%v inProgress=%v completions=%v",
result, ds.name, bc.cancelled, bc.archived, bc.inProgress, bc.hasCompletions)
}
}
}
// TestCategorizeTask_CancelledAlwaysCancelled verifies the property that any
// task with IsCancelled=true is always categorized into ColumnCancelled,
// regardless of all other field values.
func TestCategorizeTask_CancelledAlwaysCancelled(t *testing.T) {
now := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
daysThreshold := 30
past := now.AddDate(0, 0, -5)
future := now.AddDate(0, 0, 10)
farFuture := now.AddDate(0, 0, 60)
dates := []*time.Time{nil, &past, &future, &farFuture}
bools := []bool{true, false}
for _, isArchived := range bools {
for _, inProgress := range bools {
for _, hasCompletions := range bools {
for _, dueDate := range dates {
for _, nextDueDate := range dates {
task := &models.Task{
IsCancelled: true, // always cancelled
IsArchived: isArchived,
InProgress: inProgress,
DueDate: dueDate,
NextDueDate: nextDueDate,
}
if hasCompletions {
task.Completions = []models.TaskCompletion{
{BaseModel: models.BaseModel{ID: 1}},
}
} else {
task.Completions = []models.TaskCompletion{}
}
result := categorization.CategorizeTaskWithTime(task, daysThreshold, now)
assert.Equal(t, categorization.ColumnCancelled, result,
"cancelled task should always map to ColumnCancelled, got %q "+
"(archived=%v inProgress=%v completions=%v dueDate=%v nextDueDate=%v)",
result, isArchived, inProgress, hasCompletions, dueDate, nextDueDate)
}
}
}
}
}
}
// TestCategorizeTask_ArchivedAlwaysArchived verifies the property that any
// task with IsArchived=true and IsCancelled=false is always categorized into
// ColumnCancelled (archived tasks share the cancelled column as both represent
// "inactive" states), regardless of all other field values.
func TestCategorizeTask_ArchivedAlwaysArchived(t *testing.T) {
now := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
daysThreshold := 30
past := now.AddDate(0, 0, -5)
future := now.AddDate(0, 0, 10)
farFuture := now.AddDate(0, 0, 60)
dates := []*time.Time{nil, &past, &future, &farFuture}
bools := []bool{true, false}
for _, inProgress := range bools {
for _, hasCompletions := range bools {
for _, dueDate := range dates {
for _, nextDueDate := range dates {
task := &models.Task{
IsCancelled: false, // not cancelled
IsArchived: true, // always archived
InProgress: inProgress,
DueDate: dueDate,
NextDueDate: nextDueDate,
}
if hasCompletions {
task.Completions = []models.TaskCompletion{
{BaseModel: models.BaseModel{ID: 1}},
}
} else {
task.Completions = []models.TaskCompletion{}
}
result := categorization.CategorizeTaskWithTime(task, daysThreshold, now)
assert.Equal(t, categorization.ColumnCancelled, result,
"archived (non-cancelled) task should always map to ColumnCancelled, got %q "+
"(inProgress=%v completions=%v dueDate=%v nextDueDate=%v)",
result, inProgress, hasCompletions, dueDate, nextDueDate)
}
}
}
}
}