cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat: status & priority tracking UI; edit view

+3078 -78
+7 -2
ROADMAP.md
··· 17 17 - [x] `projects` - List all project names 18 18 - [x] Tag management system 19 19 - [x] `tags` - List all tag names 20 - - [ ] Status tracking - todo, in-progress, blocked, done, abandoned 21 - - [ ] Priority system - High/medium/low or numeric scales 20 + - [x] Status tracking - todo, in-progress, blocked, done, abandoned 21 + - [x] Status indicators in task list view (colored unicode/non-emoji symbols) 22 + - [x] Priority system - High/medium/low or numeric scales 23 + - [x] Priority indicators in task list view (โ˜…โ˜…โ˜… visual + color coding) 24 + - [x] Edit View 25 + - [x] Interactive status picker in task edit view 26 + - [x] Priority system toggle in task edit view (Text/Numeric/Legacy modes) 22 27 - [ ] Due dates & scheduling - Including recurring tasks 23 28 - [ ] Task dependencies - Task A blocks task B relationships 24 29
+39 -5
cmd/commands.go
··· 134 134 viewCmd.Flags().Bool("no-metadata", false, "Hide creation/modification timestamps") 135 135 root.AddCommand(viewCmd) 136 136 137 - root.AddCommand(&cobra.Command{ 138 - Use: "update [task-id] [options...]", 137 + updateCmd := &cobra.Command{ 138 + Use: "update [task-id]", 139 139 Short: "Update task properties", 140 - Args: cobra.MinimumNArgs(1), 140 + Args: cobra.ExactArgs(1), 141 141 RunE: func(cmd *cobra.Command, args []string) error { 142 + taskID := args[0] 143 + description, _ := cmd.Flags().GetString("description") 144 + status, _ := cmd.Flags().GetString("status") 145 + priority, _ := cmd.Flags().GetString("priority") 146 + project, _ := cmd.Flags().GetString("project") 147 + due, _ := cmd.Flags().GetString("due") 148 + addTags, _ := cmd.Flags().GetStringSlice("add-tag") 149 + removeTags, _ := cmd.Flags().GetStringSlice("remove-tag") 150 + 142 151 handler, err := handlers.NewTaskHandler() 143 152 if err != nil { 144 153 return err 145 154 } 146 155 defer handler.Close() 147 - return handler.Update(cmd.Context(), args) 156 + return handler.Update(cmd.Context(), taskID, description, status, priority, project, due, addTags, removeTags) 148 157 }, 149 - }) 158 + } 159 + updateCmd.Flags().String("description", "", "Update task description") 160 + updateCmd.Flags().String("status", "", "Update task status") 161 + updateCmd.Flags().StringP("priority", "p", "", "Update task priority") 162 + updateCmd.Flags().String("project", "", "Update task project") 163 + updateCmd.Flags().StringP("due", "d", "", "Update due date (YYYY-MM-DD)") 164 + updateCmd.Flags().StringSlice("add-tag", []string{}, "Add tags to task") 165 + updateCmd.Flags().StringSlice("remove-tag", []string{}, "Remove tags from task") 166 + root.AddCommand(updateCmd) 167 + 168 + editCmd := &cobra.Command{ 169 + Use: "edit [task-id]", 170 + Short: "Edit task interactively with status picker and priority toggle", 171 + Aliases: []string{"e"}, 172 + Args: cobra.ExactArgs(1), 173 + RunE: func(cmd *cobra.Command, args []string) error { 174 + taskID := args[0] 175 + handler, err := handlers.NewTaskHandler() 176 + if err != nil { 177 + return err 178 + } 179 + defer handler.Close() 180 + return handler.EditInteractive(cmd.Context(), taskID) 181 + }, 182 + } 183 + root.AddCommand(editCmd) 150 184 151 185 root.AddCommand(&cobra.Command{ 152 186 Use: "delete [task-id]",
+66 -34
internal/handlers/tasks.go
··· 149 149 return taskList.Browse(ctx) 150 150 } 151 151 152 - func (h *TaskHandler) Update(ctx context.Context, args []string) error { 153 - if len(args) < 1 { 154 - return fmt.Errorf("task ID required") 155 - } 156 - 157 - taskID := args[0] 152 + // Update updates a task using parsed flag values 153 + func (h *TaskHandler) Update(ctx context.Context, taskID, description, status, priority, project, due string, addTags, removeTags []string) error { 158 154 var task *models.Task 159 155 var err error 160 156 ··· 168 164 return fmt.Errorf("failed to find task: %w", err) 169 165 } 170 166 171 - for i := 1; i < len(args); i++ { 172 - arg := args[i] 173 - switch { 174 - case arg == "--description" && i+1 < len(args): 175 - task.Description = args[i+1] 176 - i++ 177 - case arg == "--status" && i+1 < len(args): 178 - task.Status = args[i+1] 179 - i++ 180 - case arg == "--priority" && i+1 < len(args): 181 - task.Priority = args[i+1] 182 - i++ 183 - case arg == "--project" && i+1 < len(args): 184 - task.Project = args[i+1] 185 - i++ 186 - case arg == "--due" && i+1 < len(args): 187 - if dueTime, err := time.Parse("2006-01-02", args[i+1]); err == nil { 188 - task.Due = &dueTime 189 - } 190 - i++ 191 - case strings.HasPrefix(arg, "--add-tag="): 192 - tag := strings.TrimPrefix(arg, "--add-tag=") 193 - if !slices.Contains(task.Tags, tag) { 194 - task.Tags = append(task.Tags, tag) 195 - } 196 - case strings.HasPrefix(arg, "--remove-tag="): 197 - tag := strings.TrimPrefix(arg, "--remove-tag=") 198 - task.Tags = removeString(task.Tags, tag) 167 + if description != "" { 168 + task.Description = description 169 + } 170 + if status != "" { 171 + task.Status = status 172 + } 173 + if priority != "" { 174 + task.Priority = priority 175 + } 176 + if project != "" { 177 + task.Project = project 178 + } 179 + if due != "" { 180 + if dueTime, err := time.Parse("2006-01-02", due); err == nil { 181 + task.Due = &dueTime 182 + } else { 183 + return fmt.Errorf("invalid due date format, use YYYY-MM-DD: %w", err) 199 184 } 185 + } 186 + 187 + for _, tag := range addTags { 188 + if !slices.Contains(task.Tags, tag) { 189 + task.Tags = append(task.Tags, tag) 190 + } 191 + } 192 + 193 + for _, tag := range removeTags { 194 + task.Tags = removeString(task.Tags, tag) 200 195 } 201 196 202 197 err = h.repos.Tasks.Update(ctx, task) ··· 205 200 } 206 201 207 202 fmt.Printf("Task updated (ID: %d): %s\n", task.ID, task.Description) 203 + return nil 204 + } 205 + 206 + // EditInteractive opens an interactive task editor with status picker and priority toggle 207 + func (h *TaskHandler) EditInteractive(ctx context.Context, taskID string) error { 208 + var task *models.Task 209 + var err error 210 + 211 + if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 212 + task, err = h.repos.Tasks.Get(ctx, id) 213 + } else { 214 + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 215 + } 216 + 217 + if err != nil { 218 + return fmt.Errorf("failed to find task: %w", err) 219 + } 220 + 221 + editor := ui.NewTaskEditor(task, h.repos.Tasks, ui.TaskEditOptions{}) 222 + updated, err := editor.Edit(ctx) 223 + if err != nil { 224 + if err.Error() == "edit cancelled" { 225 + fmt.Println("Task edit cancelled") 226 + return nil 227 + } 228 + return fmt.Errorf("failed to edit task: %w", err) 229 + } 230 + 231 + fmt.Printf("Task updated (ID: %d): %s\n", updated.ID, updated.Description) 232 + fmt.Printf("Status: %s\n", ui.FormatStatusWithText(updated.Status)) 233 + if updated.Priority != "" { 234 + fmt.Printf("Priority: %s\n", ui.FormatPriorityWithText(updated.Priority)) 235 + } 236 + if updated.Project != "" { 237 + fmt.Printf("Project: %s\n", updated.Project) 238 + } 239 + 208 240 return nil 209 241 } 210 242
+16 -34
internal/handlers/tasks_test.go
··· 150 150 ctx := context.Background() 151 151 args := []string{"Task", "with", "flags"} 152 152 priority := "A" 153 - project := "test-project" 153 + project := "test-project" 154 154 due := "2024-12-31" 155 155 tags := []string{"urgent", "work"} 156 156 ··· 164 164 t.Fatalf("Failed to get pending tasks: %v", err) 165 165 } 166 166 167 - // Should have 2 tasks now (previous test created one) 168 167 if len(tasks) < 1 { 169 168 t.Errorf("Expected at least 1 task, got %d", len(tasks)) 170 169 } 171 170 172 - // Find the task we just created 173 171 var task *models.Task 174 172 for _, t := range tasks { 175 173 if t.Description == "Task with flags" { ··· 316 314 } 317 315 318 316 t.Run("updates task by ID", func(t *testing.T) { 319 - args := []string{strconv.FormatInt(id, 10), "--description", "Updated description"} 317 + taskID := strconv.FormatInt(id, 10) 320 318 321 - err := handler.Update(ctx, args) 319 + err := handler.Update(ctx, taskID, "Updated description", "", "", "", "", []string{}, []string{}) 322 320 if err != nil { 323 321 t.Errorf("UpdateTask failed: %v", err) 324 322 } ··· 334 332 }) 335 333 336 334 t.Run("updates task by UUID", func(t *testing.T) { 337 - args := []string{task.UUID, "--status", "completed"} 335 + taskID := task.UUID 338 336 339 - err := handler.Update(ctx, args) 337 + err := handler.Update(ctx, taskID, "", "completed", "", "", "", []string{}, []string{}) 340 338 if err != nil { 341 339 t.Errorf("UpdateTask by UUID failed: %v", err) 342 340 } ··· 352 350 }) 353 351 354 352 t.Run("updates multiple fields", func(t *testing.T) { 355 - args := []string{ 356 - strconv.FormatInt(id, 10), 357 - "--description", "Multiple updates", 358 - "--priority", "B", 359 - "--project", "test", 360 - "--due", "2024-12-31", 361 - } 353 + taskID := strconv.FormatInt(id, 10) 362 354 363 - err := handler.Update(ctx, args) 355 + err := handler.Update(ctx, taskID, "Multiple updates", "", "B", "test", "2024-12-31", []string{}, []string{}) 364 356 if err != nil { 365 357 t.Errorf("UpdateTask with multiple fields failed: %v", err) 366 358 } 367 359 368 - // Verify all updates 369 360 updatedTask, err := handler.repos.Tasks.Get(ctx, id) 370 361 if err != nil { 371 362 t.Fatalf("Failed to get updated task: %v", err) ··· 386 377 }) 387 378 388 379 t.Run("adds and removes tags", func(t *testing.T) { 389 - args := []string{ 390 - strconv.FormatInt(id, 10), 391 - "--add-tag=work", 392 - "--add-tag=urgent", 393 - } 380 + taskID := strconv.FormatInt(id, 10) 394 381 395 - err := handler.Update(ctx, args) 382 + err := handler.Update(ctx, taskID, "", "", "", "", "", []string{"work", "urgent"}, []string{}) 396 383 if err != nil { 397 384 t.Errorf("UpdateTask with add tags failed: %v", err) 398 385 } ··· 406 393 t.Errorf("Expected 2 tags, got %d", len(updatedTask.Tags)) 407 394 } 408 395 409 - args = []string{ 410 - strconv.FormatInt(id, 10), 411 - "--remove-tag=urgent", 412 - } 396 + taskID = strconv.FormatInt(id, 10) 413 397 414 - err = handler.Update(ctx, args) 398 + err = handler.Update(ctx, taskID, "", "", "", "", "", []string{}, []string{"urgent"}) 415 399 if err != nil { 416 400 t.Errorf("UpdateTask with remove tag failed: %v", err) 417 401 } ··· 431 415 }) 432 416 433 417 t.Run("fails with missing task ID", func(t *testing.T) { 434 - args := []string{} 435 - 436 - err := handler.Update(ctx, args) 418 + err := handler.Update(ctx, "", "", "", "", "", "", []string{}, []string{}) 437 419 if err == nil { 438 420 t.Error("Expected error for missing task ID") 439 421 } 440 422 441 - if !strings.Contains(err.Error(), "task ID required") { 442 - t.Errorf("Expected error about required task ID, got: %v", err) 423 + if !strings.Contains(err.Error(), "failed to find task") { 424 + t.Errorf("Expected error about task not found, got: %v", err) 443 425 } 444 426 }) 445 427 446 428 t.Run("fails with invalid task ID", func(t *testing.T) { 447 - args := []string{"99999", "--description", "test"} 429 + taskID := "99999" 448 430 449 - err := handler.Update(ctx, args) 431 + err := handler.Update(ctx, taskID, "test", "", "", "", "", []string{}, []string{}) 450 432 if err == nil { 451 433 t.Error("Expected error for invalid task ID") 452 434 }
+112
internal/models/models.go
··· 2 2 3 3 import ( 4 4 "encoding/json" 5 + "slices" 5 6 "time" 7 + ) 8 + 9 + type TaskStatus string 10 + type TaskPriority string 11 + type TaskWeight int 12 + 13 + // TODO: Use [TaskStatus] 14 + const ( 15 + StatusTodo = "todo" 16 + StatusInProgress = "in-progress" 17 + StatusBlocked = "blocked" 18 + StatusDone = "done" 19 + StatusAbandoned = "abandoned" 20 + StatusPending = "pending" 21 + StatusCompleted = "completed" 22 + StatusDeleted = "deleted" 23 + ) 24 + 25 + // TODO: Use [TaskPriority] 26 + const ( 27 + PriorityHigh = "High" 28 + PriorityMedium = "Medium" 29 + PriorityLow = "Low" 30 + ) 31 + 32 + // TODO: Use [TaskWeight] 33 + const ( 34 + PriorityNumericMin = 1 35 + PriorityNumericMax = 5 6 36 ) 7 37 8 38 // Model defines the common interface that all domain models must implement ··· 169 199 // HasPriority returns true if the task has a priority set 170 200 func (t *Task) HasPriority() bool { 171 201 return t.Priority != "" 202 + } 203 + 204 + // New status tracking methods 205 + func (t *Task) IsTodo() bool { 206 + return t.Status == StatusTodo 207 + } 208 + 209 + func (t *Task) IsInProgress() bool { 210 + return t.Status == StatusInProgress 211 + } 212 + 213 + func (t *Task) IsBlocked() bool { 214 + return t.Status == StatusBlocked 215 + } 216 + 217 + func (t *Task) IsDone() bool { 218 + return t.Status == StatusDone 219 + } 220 + 221 + func (t *Task) IsAbandoned() bool { 222 + return t.Status == StatusAbandoned 223 + } 224 + 225 + // IsValidStatus returns true if the status is one of the defined valid statuses 226 + func (t *Task) IsValidStatus() bool { 227 + validStatuses := []string{ 228 + StatusTodo, StatusInProgress, StatusBlocked, StatusDone, StatusAbandoned, 229 + StatusPending, StatusCompleted, StatusDeleted, // legacy support 230 + } 231 + return slices.Contains(validStatuses, t.Status) 232 + } 233 + 234 + // IsValidPriority returns true if the priority is valid (text-based or numeric string) 235 + func (t *Task) IsValidPriority() bool { 236 + if t.Priority == "" { 237 + return true 238 + } 239 + 240 + textPriorities := []string{PriorityHigh, PriorityMedium, PriorityLow} 241 + if slices.Contains(textPriorities, t.Priority) { 242 + return true 243 + } 244 + 245 + if len(t.Priority) == 1 && t.Priority >= "A" && t.Priority <= "Z" { 246 + return true 247 + } 248 + 249 + switch t.Priority { 250 + case "1", "2", "3", "4", "5": 251 + return true 252 + } 253 + 254 + return false 255 + } 256 + 257 + // GetPriorityWeight returns a numeric weight for sorting priorities 258 + // 259 + // Higher numbers = higher priority 260 + func (t *Task) GetPriorityWeight() int { 261 + switch t.Priority { 262 + case PriorityHigh, "5": 263 + return 5 264 + case PriorityMedium, "4": 265 + return 4 266 + case PriorityLow, "3": 267 + return 3 268 + case "2": 269 + return 2 270 + case "1": 271 + return 1 272 + case "A": 273 + return 26 274 + case "B": 275 + return 25 276 + case "C": 277 + return 24 278 + default: 279 + if len(t.Priority) == 1 && t.Priority >= "A" && t.Priority <= "Z" { 280 + return int('Z' - t.Priority[0] + 1) 281 + } 282 + return 0 283 + } 172 284 } 173 285 174 286 // IsWatched returns true if the movie has been watched
+167 -3
internal/models/models_test.go
··· 72 72 } 73 73 }) 74 74 75 + t.Run("New Status Tracking Methods", func(t *testing.T) { 76 + testCases := []struct { 77 + status string 78 + isTodo bool 79 + isInProgress bool 80 + isBlocked bool 81 + isDone bool 82 + isAbandoned bool 83 + }{ 84 + {StatusTodo, true, false, false, false, false}, 85 + {StatusInProgress, false, true, false, false, false}, 86 + {StatusBlocked, false, false, true, false, false}, 87 + {StatusDone, false, false, false, true, false}, 88 + {StatusAbandoned, false, false, false, false, true}, 89 + {"unknown", false, false, false, false, false}, 90 + } 91 + 92 + for _, tc := range testCases { 93 + task := &Task{Status: tc.status} 94 + 95 + if task.IsTodo() != tc.isTodo { 96 + t.Errorf("Status %s: expected IsTodo %v, got %v", tc.status, tc.isTodo, task.IsTodo()) 97 + } 98 + if task.IsInProgress() != tc.isInProgress { 99 + t.Errorf("Status %s: expected IsInProgress %v, got %v", tc.status, tc.isInProgress, task.IsInProgress()) 100 + } 101 + if task.IsBlocked() != tc.isBlocked { 102 + t.Errorf("Status %s: expected IsBlocked %v, got %v", tc.status, tc.isBlocked, task.IsBlocked()) 103 + } 104 + if task.IsDone() != tc.isDone { 105 + t.Errorf("Status %s: expected IsDone %v, got %v", tc.status, tc.isDone, task.IsDone()) 106 + } 107 + if task.IsAbandoned() != tc.isAbandoned { 108 + t.Errorf("Status %s: expected IsAbandoned %v, got %v", tc.status, tc.isAbandoned, task.IsAbandoned()) 109 + } 110 + } 111 + }) 112 + 113 + t.Run("Status Validation", func(t *testing.T) { 114 + validStatuses := []string{ 115 + StatusTodo, StatusInProgress, StatusBlocked, StatusDone, StatusAbandoned, 116 + StatusPending, StatusCompleted, StatusDeleted, 117 + } 118 + 119 + for _, status := range validStatuses { 120 + task := &Task{Status: status} 121 + if !task.IsValidStatus() { 122 + t.Errorf("Status %s should be valid", status) 123 + } 124 + } 125 + 126 + invalidStatuses := []string{"unknown", "invalid", ""} 127 + for _, status := range invalidStatuses { 128 + task := &Task{Status: status} 129 + if task.IsValidStatus() { 130 + t.Errorf("Status %s should be invalid", status) 131 + } 132 + } 133 + }) 134 + 75 135 t.Run("Priority Methods", func(t *testing.T) { 76 136 task := &Task{} 77 137 ··· 83 143 if !task.HasPriority() { 84 144 t.Error("Task with priority should return true for HasPriority") 85 145 } 146 + }) 147 + 148 + t.Run("Priority System", func(t *testing.T) { 149 + t.Run("Text-based Priority Validation", func(t *testing.T) { 150 + validTextPriorities := []string{ 151 + PriorityHigh, PriorityMedium, PriorityLow, 152 + } 153 + 154 + for _, priority := range validTextPriorities { 155 + task := &Task{Priority: priority} 156 + if !task.IsValidPriority() { 157 + t.Errorf("Priority %s should be valid", priority) 158 + } 159 + } 160 + }) 161 + 162 + t.Run("Numeric Priority Validation", func(t *testing.T) { 163 + validNumericPriorities := []string{"1", "2", "3", "4", "5"} 164 + 165 + for _, priority := range validNumericPriorities { 166 + task := &Task{Priority: priority} 167 + if !task.IsValidPriority() { 168 + t.Errorf("Numeric priority %s should be valid", priority) 169 + } 170 + } 171 + 172 + invalidNumericPriorities := []string{"0", "6", "10", "-1"} 173 + for _, priority := range invalidNumericPriorities { 174 + task := &Task{Priority: priority} 175 + if task.IsValidPriority() { 176 + t.Errorf("Numeric priority %s should be invalid", priority) 177 + } 178 + } 179 + }) 180 + 181 + t.Run("Legacy A-Z Priority Validation", func(t *testing.T) { 182 + validLegacyPriorities := []string{"A", "B", "C", "D", "Z"} 183 + 184 + for _, priority := range validLegacyPriorities { 185 + task := &Task{Priority: priority} 186 + if !task.IsValidPriority() { 187 + t.Errorf("Legacy priority %s should be valid", priority) 188 + } 189 + } 190 + 191 + invalidLegacyPriorities := []string{"AA", "a", "1A", ""} 192 + for _, priority := range invalidLegacyPriorities { 193 + task := &Task{Priority: priority} 194 + if priority != "" && task.IsValidPriority() { 195 + t.Errorf("Legacy priority %s should be invalid", priority) 196 + } 197 + } 198 + }) 199 + 200 + t.Run("Empty Priority Validation", func(t *testing.T) { 201 + task := &Task{Priority: ""} 202 + if !task.IsValidPriority() { 203 + t.Error("Empty priority should be valid") 204 + } 205 + }) 206 + 207 + t.Run("Priority Weight Calculation", func(t *testing.T) { 208 + testCases := []struct { 209 + priority string 210 + weight int 211 + }{ 212 + {PriorityHigh, 5}, 213 + {PriorityMedium, 4}, 214 + {PriorityLow, 3}, 215 + {"5", 5}, 216 + {"4", 4}, 217 + {"3", 3}, 218 + {"2", 2}, 219 + {"1", 1}, 220 + {"A", 26}, 221 + {"B", 25}, 222 + {"C", 24}, 223 + {"Z", 1}, 224 + {"", 0}, 225 + {"invalid", 0}, 226 + } 227 + 228 + for _, tc := range testCases { 229 + task := &Task{Priority: tc.priority} 230 + weight := task.GetPriorityWeight() 231 + if weight != tc.weight { 232 + t.Errorf("Priority %s: expected weight %d, got %d", tc.priority, tc.weight, weight) 233 + } 234 + } 235 + }) 236 + 237 + t.Run("Priority Weight Ordering", func(t *testing.T) { 238 + priorities := []string{PriorityHigh, PriorityMedium, PriorityLow} 239 + weights := []int{} 240 + 241 + for _, priority := range priorities { 242 + task := &Task{Priority: priority} 243 + weights = append(weights, task.GetPriorityWeight()) 244 + } 245 + 246 + for i := 1; i < len(weights); i++ { 247 + if weights[i-1] <= weights[i] { 248 + t.Errorf("Priority weights should be in descending order: %v", weights) 249 + } 250 + } 251 + }) 86 252 }) 87 253 88 254 t.Run("Tags Marshaling", func(t *testing.T) { ··· 866 1032 t.Errorf("Model %d: ID not set correctly", i) 867 1033 } 868 1034 869 - // Test table name method 870 1035 tableName := model.GetTableName() 871 1036 if tableName == "" { 872 1037 t.Errorf("Model %d: table name should not be empty", i) 873 1038 } 874 1039 875 - // Test timestamp methods 876 1040 now := time.Now() 877 1041 model.SetCreatedAt(now) 878 1042 model.SetUpdatedAt(now) 879 1043 880 - // Note: We don't test exact equality due to potential precision differences 1044 + // NOTE: We don't test exact equality due to potential precision differences 881 1045 if model.GetCreatedAt().IsZero() { 882 1046 t.Errorf("Model %d: created at should not be zero", i) 883 1047 }
+131
internal/repo/task_repository.go
··· 460 460 461 461 return tasks, rows.Err() 462 462 } 463 + 464 + // GetTodo retrieves all tasks with todo status 465 + func (r *TaskRepository) GetTodo(ctx context.Context) ([]*models.Task, error) { 466 + return r.List(ctx, TaskListOptions{Status: models.StatusTodo}) 467 + } 468 + 469 + // GetInProgress retrieves all tasks with in-progress status 470 + func (r *TaskRepository) GetInProgress(ctx context.Context) ([]*models.Task, error) { 471 + return r.List(ctx, TaskListOptions{Status: models.StatusInProgress}) 472 + } 473 + 474 + // GetBlocked retrieves all tasks with blocked status 475 + func (r *TaskRepository) GetBlocked(ctx context.Context) ([]*models.Task, error) { 476 + return r.List(ctx, TaskListOptions{Status: models.StatusBlocked}) 477 + } 478 + 479 + // GetDone retrieves all tasks with done status 480 + func (r *TaskRepository) GetDone(ctx context.Context) ([]*models.Task, error) { 481 + return r.List(ctx, TaskListOptions{Status: models.StatusDone}) 482 + } 483 + 484 + // GetAbandoned retrieves all tasks with abandoned status 485 + func (r *TaskRepository) GetAbandoned(ctx context.Context) ([]*models.Task, error) { 486 + return r.List(ctx, TaskListOptions{Status: models.StatusAbandoned}) 487 + } 488 + 489 + // GetByPriority retrieves all tasks with a specific priority 490 + // 491 + // We need special handling for empty priority by using raw SQL 492 + func (r *TaskRepository) GetByPriority(ctx context.Context, priority string) ([]*models.Task, error) { 493 + if priority == "" { 494 + query := ` 495 + SELECT id, uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations 496 + FROM tasks 497 + WHERE priority = '' OR priority IS NULL 498 + ORDER BY modified DESC` 499 + 500 + rows, err := r.db.QueryContext(ctx, query) 501 + if err != nil { 502 + return nil, fmt.Errorf("failed to get tasks by empty priority: %w", err) 503 + } 504 + defer rows.Close() 505 + 506 + var tasks []*models.Task 507 + for rows.Next() { 508 + task := &models.Task{} 509 + if err := r.scanTaskRow(rows, task); err != nil { 510 + return nil, err 511 + } 512 + tasks = append(tasks, task) 513 + } 514 + 515 + return tasks, rows.Err() 516 + } 517 + 518 + return r.List(ctx, TaskListOptions{Priority: priority}) 519 + } 520 + 521 + // GetHighPriority retrieves all high priority tasks 522 + func (r *TaskRepository) GetHighPriority(ctx context.Context) ([]*models.Task, error) { 523 + return r.List(ctx, TaskListOptions{Priority: models.PriorityHigh}) 524 + } 525 + 526 + // GetMediumPriority retrieves all medium priority tasks 527 + func (r *TaskRepository) GetMediumPriority(ctx context.Context) ([]*models.Task, error) { 528 + return r.List(ctx, TaskListOptions{Priority: models.PriorityMedium}) 529 + } 530 + 531 + // GetLowPriority retrieves all low priority tasks 532 + func (r *TaskRepository) GetLowPriority(ctx context.Context) ([]*models.Task, error) { 533 + return r.List(ctx, TaskListOptions{Priority: models.PriorityLow}) 534 + } 535 + 536 + // GetStatusSummary returns a summary of tasks by status 537 + func (r *TaskRepository) GetStatusSummary(ctx context.Context) (map[string]int64, error) { 538 + query := ` 539 + SELECT status, COUNT(*) as count 540 + FROM tasks 541 + GROUP BY status 542 + ORDER BY status` 543 + 544 + rows, err := r.db.QueryContext(ctx, query) 545 + if err != nil { 546 + return nil, fmt.Errorf("failed to get status summary: %w", err) 547 + } 548 + defer rows.Close() 549 + 550 + summary := make(map[string]int64) 551 + for rows.Next() { 552 + var status string 553 + var count int64 554 + if err := rows.Scan(&status, &count); err != nil { 555 + return nil, fmt.Errorf("failed to scan status summary row: %w", err) 556 + } 557 + summary[status] = count 558 + } 559 + 560 + return summary, rows.Err() 561 + } 562 + 563 + // GetPrioritySummary returns a summary of tasks by priority 564 + func (r *TaskRepository) GetPrioritySummary(ctx context.Context) (map[string]int64, error) { 565 + query := ` 566 + SELECT 567 + CASE 568 + WHEN priority = '' OR priority IS NULL THEN 'No Priority' 569 + ELSE priority 570 + END as priority_group, 571 + COUNT(*) as count 572 + FROM tasks 573 + GROUP BY priority_group 574 + ORDER BY priority_group` 575 + 576 + rows, err := r.db.QueryContext(ctx, query) 577 + if err != nil { 578 + return nil, fmt.Errorf("failed to get priority summary: %w", err) 579 + } 580 + defer rows.Close() 581 + 582 + summary := make(map[string]int64) 583 + for rows.Next() { 584 + var priority string 585 + var count int64 586 + if err := rows.Scan(&priority, &count); err != nil { 587 + return nil, fmt.Errorf("failed to scan priority summary row: %w", err) 588 + } 589 + summary[priority] = count 590 + } 591 + 592 + return summary, rows.Err() 593 + }
+319
internal/repo/task_repository_test.go
··· 501 501 } 502 502 }) 503 503 }) 504 + 505 + t.Run("New Status Tracking Methods", func(t *testing.T) { 506 + statusTasks := []*models.Task{ 507 + {UUID: newUUID(), Description: "Todo task", Status: models.StatusTodo, Project: "test"}, 508 + {UUID: newUUID(), Description: "In progress task", Status: models.StatusInProgress, Project: "test"}, 509 + {UUID: newUUID(), Description: "Blocked task", Status: models.StatusBlocked, Project: "test"}, 510 + {UUID: newUUID(), Description: "Done task", Status: models.StatusDone, Project: "test"}, 511 + {UUID: newUUID(), Description: "Abandoned task", Status: models.StatusAbandoned, Project: "test"}, 512 + } 513 + 514 + for _, task := range statusTasks { 515 + _, err := repo.Create(ctx, task) 516 + if err != nil { 517 + t.Fatalf("Failed to create task: %v", err) 518 + } 519 + } 520 + 521 + t.Run("GetTodo", func(t *testing.T) { 522 + results, err := repo.GetTodo(ctx) 523 + if err != nil { 524 + t.Errorf("Failed to get todo tasks: %v", err) 525 + } 526 + 527 + if len(results) < 1 { 528 + t.Errorf("Expected at least 1 todo task, got %d", len(results)) 529 + } 530 + 531 + for _, task := range results { 532 + if task.Status != models.StatusTodo { 533 + t.Errorf("Expected status %s, got %s", models.StatusTodo, task.Status) 534 + } 535 + } 536 + }) 537 + 538 + t.Run("GetInProgress", func(t *testing.T) { 539 + results, err := repo.GetInProgress(ctx) 540 + if err != nil { 541 + t.Errorf("Failed to get in-progress tasks: %v", err) 542 + } 543 + 544 + if len(results) < 1 { 545 + t.Errorf("Expected at least 1 in-progress task, got %d", len(results)) 546 + } 547 + 548 + for _, task := range results { 549 + if task.Status != models.StatusInProgress { 550 + t.Errorf("Expected status %s, got %s", models.StatusInProgress, task.Status) 551 + } 552 + } 553 + }) 554 + 555 + t.Run("GetBlocked", func(t *testing.T) { 556 + results, err := repo.GetBlocked(ctx) 557 + if err != nil { 558 + t.Errorf("Failed to get blocked tasks: %v", err) 559 + } 560 + 561 + if len(results) < 1 { 562 + t.Errorf("Expected at least 1 blocked task, got %d", len(results)) 563 + } 564 + 565 + for _, task := range results { 566 + if task.Status != models.StatusBlocked { 567 + t.Errorf("Expected status %s, got %s", models.StatusBlocked, task.Status) 568 + } 569 + } 570 + }) 571 + 572 + t.Run("GetDone", func(t *testing.T) { 573 + results, err := repo.GetDone(ctx) 574 + if err != nil { 575 + t.Errorf("Failed to get done tasks: %v", err) 576 + } 577 + 578 + if len(results) < 1 { 579 + t.Errorf("Expected at least 1 done task, got %d", len(results)) 580 + } 581 + 582 + for _, task := range results { 583 + if task.Status != models.StatusDone { 584 + t.Errorf("Expected status %s, got %s", models.StatusDone, task.Status) 585 + } 586 + } 587 + }) 588 + 589 + t.Run("GetAbandoned", func(t *testing.T) { 590 + results, err := repo.GetAbandoned(ctx) 591 + if err != nil { 592 + t.Errorf("Failed to get abandoned tasks: %v", err) 593 + } 594 + 595 + if len(results) < 1 { 596 + t.Errorf("Expected at least 1 abandoned task, got %d", len(results)) 597 + } 598 + 599 + for _, task := range results { 600 + if task.Status != models.StatusAbandoned { 601 + t.Errorf("Expected status %s, got %s", models.StatusAbandoned, task.Status) 602 + } 603 + } 604 + }) 605 + }) 606 + 607 + t.Run("Priority System Methods", func(t *testing.T) { 608 + priorityTasks := []*models.Task{ 609 + {UUID: newUUID(), Description: "High priority task", Status: "pending", Priority: models.PriorityHigh}, 610 + {UUID: newUUID(), Description: "Medium priority task", Status: "pending", Priority: models.PriorityMedium}, 611 + {UUID: newUUID(), Description: "Low priority task", Status: "pending", Priority: models.PriorityLow}, 612 + {UUID: newUUID(), Description: "Numeric 5 priority", Status: "pending", Priority: "5"}, 613 + {UUID: newUUID(), Description: "Numeric 3 priority", Status: "pending", Priority: "3"}, 614 + {UUID: newUUID(), Description: "Numeric 1 priority", Status: "pending", Priority: "1"}, 615 + {UUID: newUUID(), Description: "Legacy A priority", Status: "pending", Priority: "A"}, 616 + {UUID: newUUID(), Description: "Legacy B priority", Status: "pending", Priority: "B"}, 617 + {UUID: newUUID(), Description: "No priority task", Status: "pending", Priority: ""}, 618 + } 619 + 620 + for _, task := range priorityTasks { 621 + _, err := repo.Create(ctx, task) 622 + if err != nil { 623 + t.Fatalf("Failed to create task: %v", err) 624 + } 625 + } 626 + 627 + t.Run("GetHighPriority", func(t *testing.T) { 628 + results, err := repo.GetHighPriority(ctx) 629 + if err != nil { 630 + t.Errorf("Failed to get high priority tasks: %v", err) 631 + } 632 + 633 + if len(results) < 1 { 634 + t.Errorf("Expected at least 1 high priority task, got %d", len(results)) 635 + } 636 + 637 + for _, task := range results { 638 + if task.Priority != models.PriorityHigh { 639 + t.Errorf("Expected priority %s, got %s", models.PriorityHigh, task.Priority) 640 + } 641 + } 642 + }) 643 + 644 + t.Run("GetMediumPriority", func(t *testing.T) { 645 + results, err := repo.GetMediumPriority(ctx) 646 + if err != nil { 647 + t.Errorf("Failed to get medium priority tasks: %v", err) 648 + } 649 + 650 + if len(results) < 1 { 651 + t.Errorf("Expected at least 1 medium priority task, got %d", len(results)) 652 + } 653 + 654 + for _, task := range results { 655 + if task.Priority != models.PriorityMedium { 656 + t.Errorf("Expected priority %s, got %s", models.PriorityMedium, task.Priority) 657 + } 658 + } 659 + }) 660 + 661 + t.Run("GetLowPriority", func(t *testing.T) { 662 + results, err := repo.GetLowPriority(ctx) 663 + if err != nil { 664 + t.Errorf("Failed to get low priority tasks: %v", err) 665 + } 666 + 667 + if len(results) < 1 { 668 + t.Errorf("Expected at least 1 low priority task, got %d", len(results)) 669 + } 670 + 671 + for _, task := range results { 672 + if task.Priority != models.PriorityLow { 673 + t.Errorf("Expected priority %s, got %s", models.PriorityLow, task.Priority) 674 + } 675 + } 676 + }) 677 + 678 + t.Run("GetByPriority", func(t *testing.T) { 679 + // Test numeric priority 680 + results, err := repo.GetByPriority(ctx, "5") 681 + if err != nil { 682 + t.Errorf("Failed to get tasks by priority 5: %v", err) 683 + } 684 + 685 + if len(results) < 1 { 686 + t.Errorf("Expected at least 1 task with priority 5, got %d", len(results)) 687 + } 688 + 689 + for _, task := range results { 690 + if task.Priority != "5" { 691 + t.Errorf("Expected priority 5, got %s", task.Priority) 692 + } 693 + } 694 + 695 + // Test legacy priority 696 + results, err = repo.GetByPriority(ctx, "A") 697 + if err != nil { 698 + t.Errorf("Failed to get tasks by priority A: %v", err) 699 + } 700 + 701 + if len(results) < 1 { 702 + t.Errorf("Expected at least 1 task with priority A, got %d", len(results)) 703 + } 704 + 705 + for _, task := range results { 706 + if task.Priority != "A" { 707 + t.Errorf("Expected priority A, got %s", task.Priority) 708 + } 709 + } 710 + 711 + // Test empty priority - create a specific task with no priority for this test 712 + noPriorityTask := &models.Task{ 713 + UUID: newUUID(), 714 + Description: "No priority task for test", 715 + Status: "pending", 716 + Priority: "", 717 + } 718 + _, err = repo.Create(ctx, noPriorityTask) 719 + if err != nil { 720 + t.Fatalf("Failed to create no priority task: %v", err) 721 + } 722 + 723 + results, err = repo.GetByPriority(ctx, "") 724 + if err != nil { 725 + t.Errorf("Failed to get tasks with no priority: %v", err) 726 + } 727 + 728 + if len(results) < 1 { 729 + t.Errorf("Expected at least 1 task with no priority, got %d", len(results)) 730 + } 731 + 732 + for _, task := range results { 733 + if task.Priority != "" { 734 + t.Errorf("Expected empty priority, got %s", task.Priority) 735 + } 736 + } 737 + }) 738 + }) 739 + 740 + t.Run("Summary Methods", func(t *testing.T) { 741 + summaryTasks := []*models.Task{ 742 + {UUID: newUUID(), Description: "Summary task 1", Status: models.StatusTodo, Priority: models.PriorityHigh}, 743 + {UUID: newUUID(), Description: "Summary task 2", Status: models.StatusTodo, Priority: models.PriorityMedium}, 744 + {UUID: newUUID(), Description: "Summary task 3", Status: models.StatusInProgress, Priority: models.PriorityHigh}, 745 + {UUID: newUUID(), Description: "Summary task 4", Status: models.StatusDone, Priority: models.PriorityLow}, 746 + {UUID: newUUID(), Description: "Summary task 5", Status: models.StatusBlocked, Priority: ""}, 747 + {UUID: newUUID(), Description: "Summary task 6", Status: models.StatusAbandoned, Priority: "5"}, 748 + } 749 + 750 + for _, task := range summaryTasks { 751 + _, err := repo.Create(ctx, task) 752 + if err != nil { 753 + t.Fatalf("Failed to create summary task: %v", err) 754 + } 755 + } 756 + 757 + t.Run("GetStatusSummary", func(t *testing.T) { 758 + summary, err := repo.GetStatusSummary(ctx) 759 + if err != nil { 760 + t.Errorf("Failed to get status summary: %v", err) 761 + } 762 + 763 + if len(summary) == 0 { 764 + t.Error("Expected non-empty status summary") 765 + } 766 + 767 + // Check that we have expected statuses with counts 768 + expectedStatuses := []string{ 769 + models.StatusTodo, models.StatusInProgress, models.StatusDone, 770 + models.StatusBlocked, models.StatusAbandoned, 771 + } 772 + 773 + for _, status := range expectedStatuses { 774 + if count, exists := summary[status]; exists { 775 + if count < 1 { 776 + t.Errorf("Expected at least 1 task with status %s, got %d", status, count) 777 + } 778 + } else { 779 + t.Errorf("Expected status %s in summary", status) 780 + } 781 + } 782 + 783 + if todoCount := summary[models.StatusTodo]; todoCount < 2 { 784 + t.Errorf("Expected at least 2 todo tasks, got %d", todoCount) 785 + } 786 + }) 787 + 788 + t.Run("GetPrioritySummary", func(t *testing.T) { 789 + summary, err := repo.GetPrioritySummary(ctx) 790 + if err != nil { 791 + t.Errorf("Failed to get priority summary: %v", err) 792 + } 793 + 794 + if len(summary) == 0 { 795 + t.Error("Expected non-empty priority summary") 796 + } 797 + 798 + expectedPriorities := []string{models.PriorityHigh, models.PriorityMedium, models.PriorityLow, "5"} 799 + 800 + for _, priority := range expectedPriorities { 801 + if count, exists := summary[priority]; exists { 802 + if count < 1 { 803 + t.Errorf("Expected at least 1 task with priority %s, got %d", priority, count) 804 + } 805 + } else { 806 + t.Errorf("Expected priority %s in summary", priority) 807 + } 808 + } 809 + 810 + if noPriorityCount, exists := summary["No Priority"]; exists { 811 + if noPriorityCount < 1 { 812 + t.Errorf("Expected at least 1 task with no priority, got %d", noPriorityCount) 813 + } 814 + } else { 815 + t.Error("Expected 'No Priority' group in summary") 816 + } 817 + 818 + if highCount := summary[models.PriorityHigh]; highCount < 2 { 819 + t.Errorf("Expected at least 2 high priority tasks, got %d", highCount) 820 + } 821 + }) 822 + }) 504 823 }
+548
internal/ui/task_edit.go
··· 1 + package ui 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "os" 8 + "strings" 9 + 10 + "github.com/charmbracelet/bubbles/help" 11 + "github.com/charmbracelet/bubbles/key" 12 + "github.com/charmbracelet/bubbles/textinput" 13 + tea "github.com/charmbracelet/bubbletea" 14 + "github.com/charmbracelet/lipgloss" 15 + "github.com/stormlightlabs/noteleaf/internal/models" 16 + ) 17 + 18 + type TaskEditOptions struct { 19 + Output io.Writer 20 + Input io.Reader 21 + Width int 22 + Height int 23 + } 24 + 25 + type TaskEditor struct { 26 + task *models.Task 27 + repo TaskRepository 28 + opts TaskEditOptions 29 + } 30 + 31 + func NewTaskEditor(task *models.Task, repo TaskRepository, opts TaskEditOptions) *TaskEditor { 32 + if opts.Output == nil { 33 + opts.Output = os.Stdout 34 + } 35 + if opts.Input == nil { 36 + opts.Input = os.Stdin 37 + } 38 + if opts.Width == 0 { 39 + opts.Width = 80 40 + } 41 + if opts.Height == 0 { 42 + opts.Height = 24 43 + } 44 + return &TaskEditor{ 45 + task: task, 46 + repo: repo, 47 + opts: opts, 48 + } 49 + } 50 + 51 + type ( 52 + editMode int 53 + priorityMode int 54 + ) 55 + 56 + const ( 57 + fieldNavigation editMode = iota 58 + statusPicker 59 + priorityPicker 60 + textInput 61 + ) 62 + 63 + const ( 64 + priorityModeText priorityMode = iota 65 + priorityModeNumeric 66 + priorityModeLegacy 67 + ) 68 + 69 + var ( 70 + statusOptions = []string{ 71 + models.StatusTodo, 72 + models.StatusInProgress, 73 + models.StatusBlocked, 74 + models.StatusDone, 75 + models.StatusAbandoned, 76 + } 77 + 78 + textPriorityOptions = []string{ 79 + "", 80 + models.PriorityLow, 81 + models.PriorityMedium, 82 + models.PriorityHigh, 83 + } 84 + 85 + numericPriorityOptions = []string{"", "1", "2", "3", "4", "5"} 86 + legacyPriorityOptions = []string{"", "A", "B", "C", "D", "E"} 87 + ) 88 + 89 + type taskEditKeyMap struct { 90 + Up key.Binding 91 + Down key.Binding 92 + Left key.Binding 93 + Right key.Binding 94 + Enter key.Binding 95 + Tab key.Binding 96 + ShiftTab key.Binding 97 + Escape key.Binding 98 + Save key.Binding 99 + Cancel key.Binding 100 + Help key.Binding 101 + StatusEdit key.Binding 102 + Priority key.Binding 103 + PriorityMode key.Binding 104 + } 105 + 106 + func (k taskEditKeyMap) ShortHelp() []key.Binding { 107 + return []key.Binding{k.Up, k.Down, k.Enter, k.Save, k.Cancel, k.Help} 108 + } 109 + 110 + func (k taskEditKeyMap) FullHelp() [][]key.Binding { 111 + return [][]key.Binding{ 112 + {k.Up, k.Down, k.Left, k.Right}, 113 + {k.Tab, k.ShiftTab, k.Enter}, 114 + {k.StatusEdit, k.Priority, k.PriorityMode}, 115 + {k.Save, k.Cancel, k.Help}, 116 + } 117 + } 118 + 119 + var taskEditKeys = taskEditKeyMap{ 120 + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("โ†‘/k", "move up")), 121 + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("โ†“/j", "move down")), 122 + Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("โ†/h", "previous")), 123 + Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("โ†’/l", "next")), 124 + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select/edit")), 125 + Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next field")), 126 + ShiftTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev field")), 127 + Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel/back")), 128 + Save: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "save")), 129 + Cancel: key.NewBinding(key.WithKeys("ctrl+c", "q"), key.WithHelp("q", "quit")), 130 + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), 131 + StatusEdit: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "edit status")), 132 + Priority: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "edit priority")), 133 + PriorityMode: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "priority mode")), 134 + } 135 + 136 + type taskEditModel struct { 137 + task *models.Task 138 + originalTask *models.Task 139 + repo TaskRepository 140 + opts TaskEditOptions 141 + keys taskEditKeyMap 142 + help help.Model 143 + 144 + mode editMode 145 + currentField int 146 + statusIndex int 147 + priorityIndex int 148 + priorityMode priorityMode 149 + 150 + descInput textinput.Model 151 + projectInput textinput.Model 152 + 153 + showingHelp bool 154 + saved bool 155 + cancelled bool 156 + 157 + fields []string 158 + } 159 + 160 + func (m taskEditModel) Init() tea.Cmd { 161 + return textinput.Blink 162 + } 163 + 164 + func (m taskEditModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 165 + var cmds []tea.Cmd 166 + 167 + switch msg := msg.(type) { 168 + case tea.KeyMsg: 169 + if m.showingHelp { 170 + switch { 171 + case key.Matches(msg, m.keys.Escape) || key.Matches(msg, m.keys.Help): 172 + m.showingHelp = false 173 + return m, nil 174 + } 175 + return m, nil 176 + } 177 + 178 + switch m.mode { 179 + case fieldNavigation: 180 + return m.updateFieldNavigation(msg) 181 + case statusPicker: 182 + return m.updateStatusPicker(msg) 183 + case priorityPicker: 184 + return m.updatePriorityPicker(msg) 185 + case textInput: 186 + return m.updateTextInput(msg) 187 + } 188 + 189 + case tea.WindowSizeMsg: 190 + m.opts.Width = msg.Width 191 + m.opts.Height = msg.Height 192 + m.descInput.Width = msg.Width - 20 193 + m.projectInput.Width = msg.Width - 20 194 + } 195 + 196 + return m, tea.Batch(cmds...) 197 + } 198 + 199 + func (m taskEditModel) updateFieldNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 200 + switch { 201 + case key.Matches(msg, m.keys.Help): 202 + m.showingHelp = true 203 + return m, nil 204 + case key.Matches(msg, m.keys.Cancel): 205 + m.cancelled = true 206 + return m, tea.Quit 207 + case key.Matches(msg, m.keys.Save): 208 + return m.saveTask() 209 + case key.Matches(msg, m.keys.Up) || key.Matches(msg, m.keys.ShiftTab): 210 + m.currentField = (m.currentField - 1 + len(m.fields)) % len(m.fields) 211 + case key.Matches(msg, m.keys.Down) || key.Matches(msg, m.keys.Tab): 212 + m.currentField = (m.currentField + 1) % len(m.fields) 213 + case key.Matches(msg, m.keys.Enter): 214 + return m.enterField() 215 + case key.Matches(msg, m.keys.StatusEdit): 216 + m.mode = statusPicker 217 + return m, nil 218 + case key.Matches(msg, m.keys.Priority): 219 + m.mode = priorityPicker 220 + return m, nil 221 + case key.Matches(msg, m.keys.PriorityMode): 222 + m.priorityMode = (m.priorityMode + 1) % 3 223 + m.updatePriorityIndex() 224 + return m, nil 225 + } 226 + return m, nil 227 + } 228 + 229 + func (m taskEditModel) updateStatusPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 230 + switch { 231 + case key.Matches(msg, m.keys.Escape): 232 + m.mode = fieldNavigation 233 + case key.Matches(msg, m.keys.Up) || key.Matches(msg, m.keys.Left): 234 + m.statusIndex = (m.statusIndex - 1 + len(statusOptions)) % len(statusOptions) 235 + case key.Matches(msg, m.keys.Down) || key.Matches(msg, m.keys.Right): 236 + m.statusIndex = (m.statusIndex + 1) % len(statusOptions) 237 + case key.Matches(msg, m.keys.Enter): 238 + m.task.Status = statusOptions[m.statusIndex] 239 + m.mode = fieldNavigation 240 + } 241 + return m, nil 242 + } 243 + 244 + func (m taskEditModel) updatePriorityPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 245 + var options []string 246 + switch m.priorityMode { 247 + case priorityModeText: 248 + options = textPriorityOptions 249 + case priorityModeNumeric: 250 + options = numericPriorityOptions 251 + case priorityModeLegacy: 252 + options = legacyPriorityOptions 253 + } 254 + 255 + switch { 256 + case key.Matches(msg, m.keys.Escape): 257 + m.mode = fieldNavigation 258 + case key.Matches(msg, m.keys.Up) || key.Matches(msg, m.keys.Left): 259 + m.priorityIndex = (m.priorityIndex - 1 + len(options)) % len(options) 260 + case key.Matches(msg, m.keys.Down) || key.Matches(msg, m.keys.Right): 261 + m.priorityIndex = (m.priorityIndex + 1) % len(options) 262 + case key.Matches(msg, m.keys.Enter): 263 + m.task.Priority = options[m.priorityIndex] 264 + m.mode = fieldNavigation 265 + case key.Matches(msg, m.keys.PriorityMode): 266 + m.priorityMode = (m.priorityMode + 1) % 3 267 + m.updatePriorityIndex() 268 + } 269 + return m, nil 270 + } 271 + 272 + func (m taskEditModel) updateTextInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 273 + var cmd tea.Cmd 274 + 275 + switch { 276 + case key.Matches(msg, m.keys.Escape): 277 + m.mode = fieldNavigation 278 + return m, nil 279 + case key.Matches(msg, m.keys.Enter): 280 + switch m.fields[m.currentField] { 281 + case "Description": 282 + m.task.Description = m.descInput.Value() 283 + case "Project": 284 + m.task.Project = m.projectInput.Value() 285 + } 286 + m.mode = fieldNavigation 287 + return m, nil 288 + } 289 + 290 + switch m.fields[m.currentField] { 291 + case "Description": 292 + m.descInput, cmd = m.descInput.Update(msg) 293 + case "Project": 294 + m.projectInput, cmd = m.projectInput.Update(msg) 295 + } 296 + 297 + return m, cmd 298 + } 299 + 300 + func (m taskEditModel) enterField() (tea.Model, tea.Cmd) { 301 + switch m.fields[m.currentField] { 302 + case "Description": 303 + m.mode = textInput 304 + m.descInput.Focus() 305 + return m, textinput.Blink 306 + case "Status": 307 + m.mode = statusPicker 308 + return m, nil 309 + case "Priority": 310 + m.mode = priorityPicker 311 + return m, nil 312 + case "Project": 313 + m.mode = textInput 314 + m.projectInput.Focus() 315 + return m, textinput.Blink 316 + } 317 + return m, nil 318 + } 319 + 320 + func (m *taskEditModel) updatePriorityIndex() { 321 + var options []string 322 + switch m.priorityMode { 323 + case priorityModeText: 324 + options = textPriorityOptions 325 + case priorityModeNumeric: 326 + options = numericPriorityOptions 327 + case priorityModeLegacy: 328 + options = legacyPriorityOptions 329 + } 330 + 331 + for i, opt := range options { 332 + if opt == m.task.Priority { 333 + m.priorityIndex = i 334 + return 335 + } 336 + } 337 + m.priorityIndex = 0 338 + } 339 + 340 + func (m taskEditModel) saveTask() (tea.Model, tea.Cmd) { 341 + m.saved = true 342 + return m, tea.Quit 343 + } 344 + 345 + func (m taskEditModel) View() string { 346 + if m.showingHelp { 347 + return m.help.View(m.keys) 348 + } 349 + 350 + var content strings.Builder 351 + 352 + title := TitleColorStyle.Render("Edit Task") 353 + content.WriteString(title + "\n\n") 354 + 355 + for i, field := range m.fields { 356 + fieldStyle := lipgloss.NewStyle() 357 + if i == m.currentField && m.mode == fieldNavigation { 358 + fieldStyle = SelectedColorStyle 359 + } 360 + 361 + switch field { 362 + case "Description": 363 + value := m.task.Description 364 + if m.mode == textInput && i == m.currentField { 365 + value = m.descInput.View() 366 + } 367 + content.WriteString(fieldStyle.Render(fmt.Sprintf("Description: %s", value)) + "\n") 368 + 369 + case "Status": 370 + statusStr := m.renderStatusField() 371 + content.WriteString(fieldStyle.Render(fmt.Sprintf("Status: %s", statusStr)) + "\n") 372 + 373 + case "Priority": 374 + priorityStr := m.renderPriorityField() 375 + content.WriteString(fieldStyle.Render(fmt.Sprintf("Priority: %s", priorityStr)) + "\n") 376 + 377 + case "Project": 378 + value := m.task.Project 379 + if m.mode == textInput && i == m.currentField { 380 + value = m.projectInput.View() 381 + } 382 + content.WriteString(fieldStyle.Render(fmt.Sprintf("Project: %s", value)) + "\n") 383 + } 384 + content.WriteString("\n") 385 + } 386 + 387 + switch m.mode { 388 + case statusPicker: 389 + content.WriteString(m.renderStatusPicker()) 390 + case priorityPicker: 391 + content.WriteString(m.renderPriorityPicker()) 392 + } 393 + 394 + help := m.help.View(m.keys) 395 + 396 + return lipgloss.JoinVertical(lipgloss.Left, content.String(), help) 397 + } 398 + 399 + func (m taskEditModel) renderStatusField() string { 400 + if m.mode == statusPicker { 401 + return StatusLegend() 402 + } 403 + return FormatStatusWithText(m.task.Status) 404 + } 405 + 406 + func (m taskEditModel) renderPriorityField() string { 407 + if m.mode == priorityPicker { 408 + modeStr := "" 409 + switch m.priorityMode { 410 + case priorityModeText: 411 + modeStr = "Text" 412 + case priorityModeNumeric: 413 + modeStr = "Numeric" 414 + case priorityModeLegacy: 415 + modeStr = "Legacy" 416 + } 417 + return fmt.Sprintf("%s (Mode: %s)", PriorityLegend(), modeStr) 418 + } 419 + return FormatPriorityWithText(m.task.Priority) 420 + } 421 + 422 + func (m taskEditModel) renderStatusPicker() string { 423 + var content strings.Builder 424 + content.WriteString("Select Status:\n") 425 + 426 + for i, status := range statusOptions { 427 + style := lipgloss.NewStyle() 428 + if i == m.statusIndex { 429 + style = SelectedColorStyle 430 + } 431 + 432 + line := fmt.Sprintf("%s %s", FormatStatusIndicator(status), status) 433 + content.WriteString(style.Render(line) + "\n") 434 + } 435 + 436 + return content.String() 437 + } 438 + 439 + func (m taskEditModel) renderPriorityPicker() string { 440 + var content strings.Builder 441 + 442 + modeStr := "" 443 + var options []string 444 + 445 + switch m.priorityMode { 446 + case priorityModeText: 447 + modeStr = "Text" 448 + options = textPriorityOptions 449 + case priorityModeNumeric: 450 + modeStr = "Numeric (1=Low, 5=High)" 451 + options = numericPriorityOptions 452 + case priorityModeLegacy: 453 + modeStr = "Legacy (A=High, E=Low)" 454 + options = legacyPriorityOptions 455 + } 456 + 457 + content.WriteString(fmt.Sprintf("Select Priority (%s - Press 'm' to switch modes):\n", modeStr)) 458 + 459 + for i, priority := range options { 460 + style := lipgloss.NewStyle() 461 + if i == m.priorityIndex { 462 + style = SelectedColorStyle 463 + } 464 + 465 + var line string 466 + if priority == "" { 467 + line = fmt.Sprintf("%s None", FormatPriorityIndicator(priority)) 468 + } else { 469 + line = fmt.Sprintf("%s %s - %s", FormatPriorityIndicator(priority), priority, GetPriorityDescription(priority)) 470 + } 471 + content.WriteString(style.Render(line) + "\n") 472 + } 473 + 474 + return content.String() 475 + } 476 + 477 + func (te *TaskEditor) Edit(ctx context.Context) (*models.Task, error) { 478 + descInput := textinput.New() 479 + descInput.SetValue(te.task.Description) 480 + descInput.Width = te.opts.Width - 20 481 + 482 + projectInput := textinput.New() 483 + projectInput.SetValue(te.task.Project) 484 + projectInput.Width = te.opts.Width - 20 485 + 486 + originalTask := *te.task 487 + 488 + statusIndex := 0 489 + for i, status := range statusOptions { 490 + if status == te.task.Status { 491 + statusIndex = i 492 + break 493 + } 494 + } 495 + 496 + priorityMode := priorityModeText 497 + if te.task.Priority != "" { 498 + switch GetPriorityDisplayType(te.task.Priority) { 499 + case "numeric": 500 + priorityMode = priorityModeNumeric 501 + case "legacy": 502 + priorityMode = priorityModeLegacy 503 + } 504 + } 505 + 506 + model := taskEditModel{ 507 + task: te.task, 508 + originalTask: &originalTask, 509 + repo: te.repo, 510 + opts: te.opts, 511 + keys: taskEditKeys, 512 + help: help.New(), 513 + 514 + mode: fieldNavigation, 515 + currentField: 0, 516 + statusIndex: statusIndex, 517 + priorityMode: priorityMode, 518 + 519 + descInput: descInput, 520 + projectInput: projectInput, 521 + 522 + fields: []string{"Description", "Status", "Priority", "Project"}, 523 + } 524 + 525 + model.updatePriorityIndex() 526 + 527 + program := tea.NewProgram(model, tea.WithInput(te.opts.Input), tea.WithOutput(te.opts.Output)) 528 + 529 + finalModel, err := program.Run() 530 + if err != nil { 531 + return nil, fmt.Errorf("failed to run task editor: %w", err) 532 + } 533 + 534 + editModel := finalModel.(taskEditModel) 535 + 536 + if editModel.cancelled { 537 + return nil, fmt.Errorf("edit cancelled") 538 + } 539 + 540 + if editModel.saved { 541 + err := te.repo.Update(ctx, te.task) 542 + if err != nil { 543 + return nil, fmt.Errorf("failed to save task: %w", err) 544 + } 545 + } 546 + 547 + return te.task, nil 548 + }
+486
internal/ui/task_edit_test.go
··· 1 + package ui 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "strings" 7 + "testing" 8 + "time" 9 + 10 + "github.com/charmbracelet/bubbles/help" 11 + "github.com/charmbracelet/bubbles/textinput" 12 + tea "github.com/charmbracelet/bubbletea" 13 + "github.com/stormlightlabs/noteleaf/internal/models" 14 + "github.com/stormlightlabs/noteleaf/internal/repo" 15 + ) 16 + 17 + type mockTaskRepo struct { 18 + tasks map[int64]*models.Task 19 + updated []*models.Task 20 + } 21 + 22 + func (m *mockTaskRepo) List(ctx context.Context, opts repo.TaskListOptions) ([]*models.Task, error) { 23 + var result []*models.Task 24 + for _, task := range m.tasks { 25 + result = append(result, task) 26 + } 27 + return result, nil 28 + } 29 + 30 + func (m *mockTaskRepo) Update(ctx context.Context, task *models.Task) error { 31 + m.updated = append(m.updated, task) 32 + if existing, exists := m.tasks[task.ID]; exists { 33 + *existing = *task 34 + } 35 + return nil 36 + } 37 + 38 + func createTestTaskEditModel(task *models.Task) taskEditModel { 39 + now := time.Now() 40 + if task.Entry.IsZero() { 41 + task.Entry = now 42 + } 43 + if task.Modified.IsZero() { 44 + task.Modified = now 45 + } 46 + 47 + repo := &mockTaskRepo{tasks: map[int64]*models.Task{task.ID: task}} 48 + 49 + model := taskEditModel{ 50 + task: task, 51 + originalTask: task, 52 + repo: repo, 53 + opts: TaskEditOptions{Output: &bytes.Buffer{}, Width: 80, Height: 24}, 54 + keys: taskEditKeys, 55 + help: help.New(), 56 + 57 + mode: fieldNavigation, 58 + currentField: 0, 59 + priorityMode: priorityModeText, 60 + 61 + fields: []string{"Description", "Status", "Priority", "Project"}, 62 + } 63 + 64 + model.descInput = textinput.New() 65 + model.descInput.SetValue(task.Description) 66 + model.projectInput = textinput.New() 67 + model.projectInput.SetValue(task.Project) 68 + 69 + for i, status := range statusOptions { 70 + if status == task.Status { 71 + model.statusIndex = i 72 + break 73 + } 74 + } 75 + 76 + model.updatePriorityIndex() 77 + 78 + return model 79 + } 80 + 81 + func TestTaskEditor(t *testing.T) { 82 + t.Run("Creation", func(t *testing.T) { 83 + task := &models.Task{ 84 + ID: 1, 85 + Description: "Test task", 86 + Status: models.StatusTodo, 87 + Priority: models.PriorityHigh, 88 + Project: "test-project", 89 + } 90 + 91 + repo := &mockTaskRepo{tasks: map[int64]*models.Task{1: task}} 92 + editor := NewTaskEditor(task, repo, TaskEditOptions{Width: 80, Height: 24}) 93 + 94 + if editor.task != task { 95 + t.Error("Task should be set correctly") 96 + } 97 + 98 + if editor.repo != repo { 99 + t.Error("Repository should be set correctly") 100 + } 101 + 102 + if editor.opts.Width != 80 { 103 + t.Errorf("Expected width 80, got %d", editor.opts.Width) 104 + } 105 + }) 106 + 107 + t.Run("Default Options", func(t *testing.T) { 108 + task := &models.Task{ID: 1} 109 + repo := &mockTaskRepo{} 110 + editor := NewTaskEditor(task, repo, TaskEditOptions{}) 111 + 112 + if editor.opts.Width != 80 { 113 + t.Errorf("Expected default width 80, got %d", editor.opts.Width) 114 + } 115 + 116 + if editor.opts.Height != 24 { 117 + t.Errorf("Expected default height 24, got %d", editor.opts.Height) 118 + } 119 + }) 120 + } 121 + 122 + func TestTaskEditModel(t *testing.T) { 123 + t.Run("Init", func(t *testing.T) { 124 + task := &models.Task{ 125 + ID: 1, 126 + Description: "Test task", 127 + Status: models.StatusInProgress, 128 + Priority: models.PriorityMedium, 129 + } 130 + 131 + model := createTestTaskEditModel(task) 132 + cmd := model.Init() 133 + if cmd == nil { 134 + t.Error("Init should return a command") 135 + } 136 + }) 137 + 138 + t.Run("Field Navigation", func(t *testing.T) { 139 + task := &models.Task{ID: 1, Description: "Test task", Status: models.StatusTodo} 140 + model := createTestTaskEditModel(task) 141 + 142 + if model.currentField != 0 { 143 + t.Errorf("Expected initial field 0, got %d", model.currentField) 144 + } 145 + 146 + msg := tea.KeyMsg{Type: tea.KeyDown} 147 + updatedModel, _ := model.Update(msg) 148 + model = updatedModel.(taskEditModel) 149 + 150 + if model.currentField != 1 { 151 + t.Errorf("Expected field 1 after down, got %d", model.currentField) 152 + } 153 + 154 + msg = tea.KeyMsg{Type: tea.KeyUp} 155 + updatedModel, _ = model.Update(msg) 156 + model = updatedModel.(taskEditModel) 157 + 158 + if model.currentField != 0 { 159 + t.Errorf("Expected field 0 after up, got %d", model.currentField) 160 + } 161 + }) 162 + 163 + t.Run("Status Picker", func(t *testing.T) { 164 + task := &models.Task{ID: 1, Description: "Test task", Status: models.StatusTodo} 165 + model := createTestTaskEditModel(task) 166 + model.currentField = 1 167 + 168 + msg := tea.KeyMsg{Type: tea.KeyEnter} 169 + updatedModel, _ := model.Update(msg) 170 + model = updatedModel.(taskEditModel) 171 + 172 + if model.mode != statusPicker { 173 + t.Errorf("Expected statusPicker mode, got %d", model.mode) 174 + } 175 + 176 + msg = tea.KeyMsg{Type: tea.KeyDown} 177 + updatedModel, _ = model.Update(msg) 178 + model = updatedModel.(taskEditModel) 179 + 180 + if model.statusIndex != 1 { 181 + t.Errorf("Expected status index 1, got %d", model.statusIndex) 182 + } 183 + 184 + msg = tea.KeyMsg{Type: tea.KeyEnter} 185 + updatedModel, _ = model.Update(msg) 186 + model = updatedModel.(taskEditModel) 187 + 188 + if model.task.Status != statusOptions[1] { 189 + t.Errorf("Expected status %s, got %s", statusOptions[1], model.task.Status) 190 + } 191 + 192 + if model.mode != fieldNavigation { 193 + t.Errorf("Expected fieldNavigation mode after selection, got %d", model.mode) 194 + } 195 + }) 196 + 197 + t.Run("Priority Picker", func(t *testing.T) { 198 + task := &models.Task{ID: 1, Description: "Test task", Priority: ""} 199 + model := createTestTaskEditModel(task) 200 + model.currentField = 2 201 + 202 + msg := tea.KeyMsg{Type: tea.KeyEnter} 203 + updatedModel, _ := model.Update(msg) 204 + model = updatedModel.(taskEditModel) 205 + 206 + if model.mode != priorityPicker { 207 + t.Errorf("Expected priorityPicker mode, got %d", model.mode) 208 + } 209 + 210 + msg = tea.KeyMsg{Type: tea.KeyDown} 211 + updatedModel, _ = model.Update(msg) 212 + model = updatedModel.(taskEditModel) 213 + 214 + if model.priorityIndex != 1 { 215 + t.Errorf("Expected priority index 1, got %d", model.priorityIndex) 216 + } 217 + 218 + msg = tea.KeyMsg{Type: tea.KeyEnter} 219 + updatedModel, _ = model.Update(msg) 220 + model = updatedModel.(taskEditModel) 221 + 222 + expectedPriority := textPriorityOptions[1] 223 + if model.task.Priority != expectedPriority { 224 + t.Errorf("Expected priority %s, got %s", expectedPriority, model.task.Priority) 225 + } 226 + }) 227 + 228 + t.Run("Priority Mode Switch", func(t *testing.T) { 229 + task := &models.Task{ID: 1, Priority: models.PriorityHigh} 230 + model := createTestTaskEditModel(task) 231 + model.mode = priorityPicker 232 + 233 + if model.priorityMode != priorityModeText { 234 + t.Errorf("Expected text priority mode initially, got %d", model.priorityMode) 235 + } 236 + 237 + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}} 238 + updatedModel, _ := model.Update(msg) 239 + model = updatedModel.(taskEditModel) 240 + 241 + if model.priorityMode != priorityModeNumeric { 242 + t.Errorf("Expected numeric priority mode, got %d", model.priorityMode) 243 + } 244 + 245 + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}} 246 + updatedModel, _ = model.Update(msg) 247 + model = updatedModel.(taskEditModel) 248 + 249 + if model.priorityMode != priorityModeLegacy { 250 + t.Errorf("Expected legacy priority mode, got %d", model.priorityMode) 251 + } 252 + 253 + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}} 254 + updatedModel, _ = model.Update(msg) 255 + model = updatedModel.(taskEditModel) 256 + 257 + if model.priorityMode != priorityModeText { 258 + t.Errorf("Expected text priority mode after full cycle, got %d", model.priorityMode) 259 + } 260 + }) 261 + 262 + t.Run("TextInput", func(t *testing.T) { 263 + task := &models.Task{ID: 1, Description: "Original description", Project: "original-project"} 264 + 265 + model := createTestTaskEditModel(task) 266 + model.currentField = 0 267 + 268 + msg := tea.KeyMsg{Type: tea.KeyEnter} 269 + updatedModel, _ := model.Update(msg) 270 + model = updatedModel.(taskEditModel) 271 + 272 + if model.mode != textInput { 273 + t.Errorf("Expected textInput mode, got %d", model.mode) 274 + } 275 + 276 + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'X'}} 277 + updatedModel, _ = model.Update(msg) 278 + model = updatedModel.(taskEditModel) 279 + 280 + msg = tea.KeyMsg{Type: tea.KeyEnter} 281 + updatedModel, _ = model.Update(msg) 282 + model = updatedModel.(taskEditModel) 283 + 284 + if model.mode != fieldNavigation { 285 + t.Errorf("Expected fieldNavigation mode after text input, got %d", model.mode) 286 + } 287 + 288 + expected := "Original descriptionX" 289 + if model.task.Description != expected { 290 + t.Errorf("Expected description %s, got %s", expected, model.task.Description) 291 + } 292 + }) 293 + 294 + t.Run("Help", func(t *testing.T) { 295 + task := &models.Task{ID: 1} 296 + model := createTestTaskEditModel(task) 297 + 298 + msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}} 299 + updatedModel, _ := model.Update(msg) 300 + model = updatedModel.(taskEditModel) 301 + 302 + if !model.showingHelp { 303 + t.Error("Expected help to be shown") 304 + } 305 + 306 + msg = tea.KeyMsg{Type: tea.KeyEsc} 307 + updatedModel, _ = model.Update(msg) 308 + model = updatedModel.(taskEditModel) 309 + 310 + if model.showingHelp { 311 + t.Error("Expected help to be hidden") 312 + } 313 + }) 314 + 315 + t.Run("Save", func(t *testing.T) { 316 + task := &models.Task{ID: 1} 317 + model := createTestTaskEditModel(task) 318 + msg := tea.KeyMsg{Type: tea.KeyCtrlS} 319 + updatedModel, cmd := model.Update(msg) 320 + model = updatedModel.(taskEditModel) 321 + 322 + if !model.saved { 323 + t.Error("Expected saved flag to be set") 324 + } 325 + 326 + if cmd == nil { 327 + t.Error("Expected quit command after save") 328 + } 329 + }) 330 + 331 + t.Run("Cancel", func(t *testing.T) { 332 + task := &models.Task{ID: 1} 333 + model := createTestTaskEditModel(task) 334 + msg := tea.KeyMsg{Type: tea.KeyCtrlC} 335 + updatedModel, cmd := model.Update(msg) 336 + model = updatedModel.(taskEditModel) 337 + 338 + if !model.cancelled { 339 + t.Error("Expected cancelled flag to be set") 340 + } 341 + 342 + if cmd == nil { 343 + t.Error("Expected quit command after cancel") 344 + } 345 + }) 346 + 347 + t.Run("View", func(t *testing.T) { 348 + task := &models.Task{ 349 + ID: 1, 350 + Description: "Test task", 351 + Status: models.StatusTodo, 352 + Priority: models.PriorityHigh, 353 + Project: "test-project", 354 + } 355 + 356 + model := createTestTaskEditModel(task) 357 + view := model.View() 358 + 359 + if !strings.Contains(view, "Edit Task") { 360 + t.Error("View should contain title") 361 + } 362 + 363 + if !strings.Contains(view, "Test task") { 364 + t.Error("View should contain task description") 365 + } 366 + 367 + if !strings.Contains(view, "test-project") { 368 + t.Error("View should contain project") 369 + } 370 + }) 371 + 372 + t.Run("Status Picker View", func(t *testing.T) { 373 + task := &models.Task{ID: 1, Status: models.StatusTodo} 374 + model := createTestTaskEditModel(task) 375 + model.mode = statusPicker 376 + 377 + view := model.View() 378 + 379 + if !strings.Contains(view, "Select Status:") { 380 + t.Error("Status picker should show selection prompt") 381 + } 382 + 383 + for _, status := range statusOptions { 384 + if !strings.Contains(view, status) { 385 + t.Errorf("Status picker should contain %s", status) 386 + } 387 + } 388 + }) 389 + 390 + t.Run("Priority Picker View", func(t *testing.T) { 391 + task := &models.Task{ID: 1, Priority: ""} 392 + model := createTestTaskEditModel(task) 393 + model.mode = priorityPicker 394 + model.priorityMode = priorityModeText 395 + 396 + view := model.View() 397 + 398 + if !strings.Contains(view, "Select Priority") { 399 + t.Error("Priority picker should show selection prompt") 400 + } 401 + 402 + if !strings.Contains(view, "Text") { 403 + t.Error("Priority picker should show current mode") 404 + } 405 + }) 406 + 407 + t.Run("KeyBindings", func(t *testing.T) { 408 + keyMap := taskEditKeys 409 + 410 + if keyMap.Up.Keys()[0] != "up" { 411 + t.Error("Up key binding should be defined") 412 + } 413 + 414 + if keyMap.StatusEdit.Keys()[0] != "s" { 415 + t.Error("Status edit key binding should be 's'") 416 + } 417 + 418 + if keyMap.Priority.Keys()[0] != "p" { 419 + t.Error("Priority key binding should be 'p'") 420 + } 421 + 422 + if keyMap.PriorityMode.Keys()[0] != "m" { 423 + t.Error("Priority mode key binding should be 'm'") 424 + } 425 + }) 426 + } 427 + 428 + func TestUpdatePriorityIndex(t *testing.T) { 429 + testCases := []struct { 430 + priority string 431 + mode priorityMode 432 + expectedIdx int 433 + }{ 434 + {models.PriorityHigh, priorityModeText, 3}, 435 + {models.PriorityMedium, priorityModeText, 2}, 436 + {models.PriorityLow, priorityModeText, 1}, 437 + {"", priorityModeText, 0}, 438 + {"3", priorityModeNumeric, 3}, 439 + {"A", priorityModeLegacy, 1}, 440 + {"unknown", priorityModeText, 0}, 441 + } 442 + 443 + for _, tc := range testCases { 444 + task := &models.Task{ID: 1, Priority: tc.priority} 445 + model := createTestTaskEditModel(task) 446 + model.priorityMode = tc.mode 447 + model.updatePriorityIndex() 448 + 449 + if model.priorityIndex != tc.expectedIdx { 450 + t.Errorf("Priority %s in mode %d should have index %d, got %d", 451 + tc.priority, tc.mode, tc.expectedIdx, model.priorityIndex) 452 + } 453 + } 454 + } 455 + 456 + func TestRenderStatusField(t *testing.T) { 457 + task := &models.Task{ID: 1, Status: models.StatusInProgress} 458 + model := createTestTaskEditModel(task) 459 + 460 + result := model.renderStatusField() 461 + if !strings.Contains(result, models.StatusInProgress) { 462 + t.Error("Status field should contain the status") 463 + } 464 + 465 + model.mode = statusPicker 466 + result = model.renderStatusField() 467 + if !strings.Contains(result, models.StatusTodo) || !strings.Contains(result, models.StatusDone) { 468 + t.Error("Status picker should show status legend") 469 + } 470 + } 471 + 472 + func TestRenderPriorityField(t *testing.T) { 473 + task := &models.Task{ID: 1, Priority: models.PriorityMedium} 474 + model := createTestTaskEditModel(task) 475 + result := model.renderPriorityField() 476 + if !strings.Contains(result, models.PriorityMedium) { 477 + t.Error("Priority field should contain the priority") 478 + } 479 + 480 + model.mode = priorityPicker 481 + model.priorityMode = priorityModeNumeric 482 + result = model.renderPriorityField() 483 + if !strings.Contains(result, "Numeric") { 484 + t.Error("Priority picker should show current mode") 485 + } 486 + }
+460
internal/ui/task_information.go
··· 1 + package ui 2 + 3 + import ( 4 + "fmt" 5 + "slices" 6 + "strings" 7 + 8 + "github.com/charmbracelet/lipgloss" 9 + "github.com/stormlightlabs/noteleaf/internal/models" 10 + ) 11 + 12 + const ( 13 + // U+25CF Black Circle 14 + StatusTodoSymbol = "โ—" 15 + // U+25D0 Circle with Left Half Black 16 + StatusInProgressSymbol = "โ—" 17 + // U+25A0 Black Square 18 + StatusBlockedSymbol = "โ– " 19 + // U+2713 Check Mark 20 + StatusDoneSymbol = "โœ“" 21 + // U+26AB Medium Black Circle 22 + StatusAbandonedSymbol = "โšซ" 23 + // U+25CB White Circle 24 + StatusPendingSymbol = "โ—‹" 25 + // U+2713 Check Mark 26 + StatusCompletedSymbol = "โœ“" 27 + // U+2717 Ballot X 28 + StatusDeletedSymbol = "โœ—" 29 + // U+2605 Black Star 30 + PriorityHighSymbol = "โ˜…" 31 + // U+2606 White Star 32 + PriorityMediumSymbol = "โ˜†" 33 + // U+25E6 White Bullet 34 + PriorityLowSymbol = "โ—ฆ" 35 + // U+25CB White Circle 36 + PriorityNoneSymbol = "โ—‹" 37 + // Three stars 38 + PriorityHighPattern = "โ˜…โ˜…โ˜…" 39 + // Two stars, one outline 40 + PriorityMediumPattern = "โ˜…โ˜…โ˜†" 41 + // One star, two outline 42 + PriorityLowPattern = "โ˜…โ˜†โ˜†" 43 + // Three outline stars 44 + PriorityNonePattern = "โ˜†โ˜†โ˜†" 45 + ) 46 + 47 + var ( 48 + // Gray 49 + TodoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) 50 + // Blue 51 + InProgressStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("12")) 52 + // Red 53 + BlockedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) 54 + // Green 55 + DoneStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) 56 + // Dark Gray 57 + AbandonedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) 58 + // Light Gray 59 + PendingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("7")) 60 + // Green 61 + CompletedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) 62 + // Dark Red 63 + DeletedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) 64 + // Bright Red - highest urgency 65 + PriorityHighStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) 66 + // Yellow - medium urgency 67 + PriorityMediumStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) 68 + // Cyan - low urgency 69 + PriorityLowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) 70 + // Gray - no priority 71 + PriorityNoneStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) 72 + // For legacy A-Z and numeric priorities 73 + PriorityLegacyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("13")) // Magenta 74 + ) 75 + 76 + // GetStatusSymbol returns the unicode symbol for a given status 77 + // 78 + // Default to pending 79 + func GetStatusSymbol(status string) string { 80 + switch status { 81 + case models.StatusTodo: 82 + return StatusTodoSymbol 83 + case models.StatusInProgress: 84 + return StatusInProgressSymbol 85 + case models.StatusBlocked: 86 + return StatusBlockedSymbol 87 + case models.StatusDone: 88 + return StatusDoneSymbol 89 + case models.StatusAbandoned: 90 + return StatusAbandonedSymbol 91 + case models.StatusPending: 92 + return StatusPendingSymbol 93 + case models.StatusCompleted: 94 + return StatusCompletedSymbol 95 + case models.StatusDeleted: 96 + return StatusDeletedSymbol 97 + default: 98 + return StatusPendingSymbol 99 + } 100 + } 101 + 102 + // GetStatusStyle returns the color style for a given status 103 + // 104 + // Defaults to pending style 105 + func GetStatusStyle(status string) lipgloss.Style { 106 + switch status { 107 + case models.StatusTodo: 108 + return TodoStyle 109 + case models.StatusInProgress: 110 + return InProgressStyle 111 + case models.StatusBlocked: 112 + return BlockedStyle 113 + case models.StatusDone: 114 + return DoneStyle 115 + case models.StatusAbandoned: 116 + return AbandonedStyle 117 + case models.StatusPending: 118 + return PendingStyle 119 + case models.StatusCompleted: 120 + return CompletedStyle 121 + case models.StatusDeleted: 122 + return DeletedStyle 123 + default: 124 + return PendingStyle 125 + } 126 + } 127 + 128 + // FormatStatusIndicator returns a styled status symbol and text 129 + func FormatStatusIndicator(status string) string { 130 + symbol := GetStatusSymbol(status) 131 + style := GetStatusStyle(status) 132 + return style.Render(symbol) 133 + } 134 + 135 + // FormatStatusWithText returns a styled status symbol with status text 136 + func FormatStatusWithText(status string) string { 137 + symbol := GetStatusSymbol(status) 138 + style := GetStatusStyle(status) 139 + return style.Render(fmt.Sprintf("%s %s", symbol, status)) 140 + } 141 + 142 + // GetStatusDescription returns a human-friendly description of the status 143 + func GetStatusDescription(status string) string { 144 + switch status { 145 + case models.StatusTodo: 146 + return "Ready to start" 147 + case models.StatusInProgress: 148 + return "Currently working" 149 + case models.StatusBlocked: 150 + return "Waiting on dependency" 151 + case models.StatusDone: 152 + return "Completed successfully" 153 + case models.StatusAbandoned: 154 + return "No longer relevant" 155 + case models.StatusPending: 156 + return "Pending (legacy)" 157 + case models.StatusCompleted: 158 + return "Completed (legacy)" 159 + case models.StatusDeleted: 160 + return "Deleted (legacy)" 161 + default: 162 + return "Unknown status" 163 + } 164 + } 165 + 166 + // FormatTaskStatus returns a complete status display with symbol, status, and description 167 + func FormatTaskStatus(task *models.Task) string { 168 + if task == nil { 169 + return "" 170 + } 171 + 172 + symbol := GetStatusSymbol(task.Status) 173 + style := GetStatusStyle(task.Status) 174 + description := GetStatusDescription(task.Status) 175 + 176 + return fmt.Sprintf("%s %s - %s", style.Render(symbol), style.Render(task.Status), description) 177 + } 178 + 179 + // StatusLegend returns a formatted legend showing all status symbols 180 + func StatusLegend() string { 181 + var parts []string 182 + 183 + statuses := []string{ 184 + models.StatusTodo, 185 + models.StatusInProgress, 186 + models.StatusBlocked, 187 + models.StatusDone, 188 + models.StatusAbandoned, 189 + } 190 + 191 + for _, status := range statuses { 192 + parts = append(parts, FormatStatusWithText(status)) 193 + } 194 + 195 + return strings.Join(parts, " ") 196 + } 197 + 198 + // GetAllStatusSymbols returns a map of all status symbols for reference 199 + func GetAllStatusSymbols() map[string]string { 200 + return map[string]string{ 201 + models.StatusTodo: StatusTodoSymbol, 202 + models.StatusInProgress: StatusInProgressSymbol, 203 + models.StatusBlocked: StatusBlockedSymbol, 204 + models.StatusDone: StatusDoneSymbol, 205 + models.StatusAbandoned: StatusAbandonedSymbol, 206 + models.StatusPending: StatusPendingSymbol, 207 + models.StatusCompleted: StatusCompletedSymbol, 208 + models.StatusDeleted: StatusDeletedSymbol, 209 + } 210 + } 211 + 212 + // IsValidStatusTransition checks if a status transition is logically valid 213 + // 214 + // From todo, can go to in-progress, blocked, done, or abandoned 215 + // From in-progress, can go to blocked, done, abandoned, or back to todo 216 + // From blocked, can go to todo, in-progress, done, or abandoned 217 + // From done, can only be reopened to todo or in-progress 218 + // From abandoned, can be reopened to todo or in-progress 219 + func IsValidStatusTransition(from, to string) bool { 220 + if from == models.StatusTodo { 221 + validTo := []string{models.StatusInProgress, models.StatusBlocked, models.StatusDone, models.StatusAbandoned} 222 + return slices.Contains(validTo, to) 223 + } 224 + 225 + if from == models.StatusInProgress { 226 + validTo := []string{models.StatusTodo, models.StatusBlocked, models.StatusDone, models.StatusAbandoned} 227 + return slices.Contains(validTo, to) 228 + } 229 + 230 + if from == models.StatusBlocked { 231 + validTo := []string{models.StatusTodo, models.StatusInProgress, models.StatusDone, models.StatusAbandoned} 232 + return slices.Contains(validTo, to) 233 + } 234 + 235 + if from == models.StatusDone { 236 + validTo := []string{models.StatusTodo, models.StatusInProgress} 237 + return slices.Contains(validTo, to) 238 + } 239 + 240 + if from == models.StatusAbandoned { 241 + validTo := []string{models.StatusTodo, models.StatusInProgress} 242 + return slices.Contains(validTo, to) 243 + } 244 + 245 + if from == models.StatusPending { 246 + return to == models.StatusTodo || to == models.StatusInProgress 247 + } 248 + 249 + if from == models.StatusCompleted { 250 + return to == models.StatusDone 251 + } 252 + 253 + return false 254 + } 255 + 256 + // GetPrioritySymbol returns the unicode symbol for a given priority 257 + func GetPrioritySymbol(priority string) string { 258 + switch priority { 259 + case models.PriorityHigh: 260 + return PriorityHighSymbol 261 + case models.PriorityMedium: 262 + return PriorityMediumSymbol 263 + case models.PriorityLow: 264 + return PriorityLowSymbol 265 + case "": 266 + return PriorityNoneSymbol 267 + default: 268 + if len(priority) == 1 && priority >= "A" && priority <= "Z" { 269 + return PriorityHighSymbol 270 + } 271 + switch priority { 272 + case "1": 273 + return PriorityLowSymbol 274 + case "2", "3": 275 + return PriorityMediumSymbol 276 + case "4", "5": 277 + return PriorityHighSymbol 278 + default: 279 + return PriorityNoneSymbol 280 + } 281 + } 282 + } 283 + 284 + // GetPriorityPattern returns the star pattern for a given priority 285 + func GetPriorityPattern(priority string) string { 286 + switch priority { 287 + case models.PriorityHigh: 288 + return PriorityHighPattern 289 + case models.PriorityMedium: 290 + return PriorityMediumPattern 291 + case models.PriorityLow: 292 + return PriorityLowPattern 293 + case "": 294 + return PriorityNonePattern 295 + default: 296 + if len(priority) == 1 && priority >= "A" && priority <= "Z" { 297 + if priority <= "C" { 298 + return PriorityHighPattern 299 + } else if priority <= "M" { 300 + return PriorityMediumPattern 301 + } else { 302 + return PriorityLowPattern 303 + } 304 + } 305 + switch priority { 306 + case "1": 307 + return PriorityLowPattern 308 + case "2", "3": 309 + return PriorityMediumPattern 310 + case "4", "5": 311 + return PriorityHighPattern 312 + default: 313 + return PriorityNonePattern 314 + } 315 + } 316 + } 317 + 318 + // GetPriorityStyle returns the color style for a given priority 319 + func GetPriorityStyle(priority string) lipgloss.Style { 320 + switch priority { 321 + case models.PriorityHigh: 322 + return PriorityHighStyle 323 + case models.PriorityMedium: 324 + return PriorityMediumStyle 325 + case models.PriorityLow: 326 + return PriorityLowStyle 327 + case "": 328 + return PriorityNoneStyle 329 + default: 330 + if len(priority) == 1 && priority >= "A" && priority <= "Z" { 331 + return PriorityLegacyStyle 332 + } 333 + switch priority { 334 + case "1", "2", "3", "4", "5": 335 + return PriorityLegacyStyle 336 + default: 337 + return PriorityNoneStyle 338 + } 339 + } 340 + } 341 + 342 + // FormatPriorityIndicator returns a styled priority pattern 343 + func FormatPriorityIndicator(priority string) string { 344 + pattern := GetPriorityPattern(priority) 345 + style := GetPriorityStyle(priority) 346 + return style.Render(pattern) 347 + } 348 + 349 + // FormatPriorityWithText returns a styled priority with text description 350 + func FormatPriorityWithText(priority string) string { 351 + pattern := GetPriorityPattern(priority) 352 + style := GetPriorityStyle(priority) 353 + 354 + if priority == "" { 355 + return style.Render(fmt.Sprintf("%s None", pattern)) 356 + } 357 + 358 + return style.Render(fmt.Sprintf("%s %s", pattern, priority)) 359 + } 360 + 361 + // GetPriorityDescription returns a human-friendly description of the priority 362 + func GetPriorityDescription(priority string) string { 363 + switch priority { 364 + case models.PriorityHigh: 365 + return "Urgent - do first" 366 + case models.PriorityMedium: 367 + return "Important - schedule soon" 368 + case models.PriorityLow: 369 + return "Nice to have - when time permits" 370 + case "": 371 + return "No priority set" 372 + default: 373 + if len(priority) == 1 && priority >= "A" && priority <= "Z" { 374 + return fmt.Sprintf("Priority %s (legacy)", priority) 375 + } 376 + switch priority { 377 + case "1": 378 + return "Priority 1 (lowest)" 379 + case "2": 380 + return "Priority 2 (low)" 381 + case "3": 382 + return "Priority 3 (medium)" 383 + case "4": 384 + return "Priority 4 (high)" 385 + case "5": 386 + return "Priority 5 (highest)" 387 + default: 388 + return "Unknown priority" 389 + } 390 + } 391 + } 392 + 393 + // FormatTaskPriority returns a complete priority display with pattern, priority, and description 394 + func FormatTaskPriority(task *models.Task) string { 395 + if task == nil { 396 + return "" 397 + } 398 + 399 + pattern := GetPriorityPattern(task.Priority) 400 + style := GetPriorityStyle(task.Priority) 401 + description := GetPriorityDescription(task.Priority) 402 + 403 + if task.Priority == "" { 404 + return fmt.Sprintf("%s %s", style.Render(pattern), description) 405 + } 406 + 407 + return fmt.Sprintf("%s %s - %s", style.Render(pattern), style.Render(task.Priority), description) 408 + } 409 + 410 + // PriorityLegend returns a formatted legend showing all priority patterns 411 + func PriorityLegend() string { 412 + var parts []string 413 + 414 + priorities := []string{ 415 + models.PriorityHigh, models.PriorityMedium, models.PriorityLow, "", 416 + } 417 + 418 + for _, priority := range priorities { 419 + parts = append(parts, FormatPriorityWithText(priority)) 420 + } 421 + 422 + return strings.Join(parts, " ") 423 + } 424 + 425 + // GetAllPrioritySymbols returns a map of all priority symbols for reference 426 + func GetAllPrioritySymbols() map[string]string { 427 + return map[string]string{ 428 + models.PriorityHigh: PriorityHighSymbol, 429 + models.PriorityMedium: PriorityMediumSymbol, 430 + models.PriorityLow: PriorityLowSymbol, 431 + "": PriorityNoneSymbol, 432 + } 433 + } 434 + 435 + // GetAllPriorityPatterns returns a map of all priority patterns for reference 436 + func GetAllPriorityPatterns() map[string]string { 437 + return map[string]string{ 438 + models.PriorityHigh: PriorityHighPattern, 439 + models.PriorityMedium: PriorityMediumPattern, 440 + models.PriorityLow: PriorityLowPattern, 441 + "": PriorityNonePattern, 442 + } 443 + } 444 + 445 + // GetPriorityDisplayType returns the display type for a priority (text, numeric, or legacy) 446 + func GetPriorityDisplayType(priority string) string { 447 + switch priority { 448 + case models.PriorityHigh, models.PriorityMedium, models.PriorityLow: 449 + return "text" 450 + case "1", "2", "3", "4", "5": 451 + return "numeric" 452 + case "": 453 + return "none" 454 + default: 455 + if len(priority) == 1 && priority >= "A" && priority <= "Z" { 456 + return "legacy" 457 + } 458 + return "unknown" 459 + } 460 + }
+727
internal/ui/task_information_test.go
··· 1 + package ui 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + 7 + "github.com/charmbracelet/lipgloss" 8 + "github.com/stormlightlabs/noteleaf/internal/models" 9 + ) 10 + 11 + func TestGetStatusSymbol(t *testing.T) { 12 + testCases := []struct { 13 + status string 14 + expected string 15 + }{ 16 + {models.StatusTodo, StatusTodoSymbol}, 17 + {models.StatusInProgress, StatusInProgressSymbol}, 18 + {models.StatusBlocked, StatusBlockedSymbol}, 19 + {models.StatusDone, StatusDoneSymbol}, 20 + {models.StatusAbandoned, StatusAbandonedSymbol}, 21 + {models.StatusPending, StatusPendingSymbol}, 22 + {models.StatusCompleted, StatusCompletedSymbol}, 23 + {models.StatusDeleted, StatusDeletedSymbol}, 24 + {"unknown", StatusPendingSymbol}, 25 + } 26 + 27 + for _, tc := range testCases { 28 + t.Run("status_"+tc.status, func(t *testing.T) { 29 + result := GetStatusSymbol(tc.status) 30 + if result != tc.expected { 31 + t.Errorf("Expected symbol %s for status %s, got %s", tc.expected, tc.status, result) 32 + } 33 + }) 34 + } 35 + } 36 + 37 + func TestGetStatusStyle(t *testing.T) { 38 + testCases := []struct { 39 + status string 40 + style lipgloss.Style 41 + }{ 42 + {models.StatusTodo, TodoStyle}, 43 + {models.StatusInProgress, InProgressStyle}, 44 + {models.StatusBlocked, BlockedStyle}, 45 + {models.StatusDone, DoneStyle}, 46 + {models.StatusAbandoned, AbandonedStyle}, 47 + {models.StatusPending, PendingStyle}, 48 + {models.StatusCompleted, CompletedStyle}, 49 + {models.StatusDeleted, DeletedStyle}, 50 + {"unknown", PendingStyle}, 51 + } 52 + 53 + for _, tc := range testCases { 54 + t.Run("style_"+tc.status, func(t *testing.T) { 55 + result := GetStatusStyle(tc.status) 56 + expectedColor := tc.style.GetForeground() 57 + resultColor := result.GetForeground() 58 + if expectedColor != resultColor { 59 + t.Errorf("Expected color %s for status %s, got %s", expectedColor, tc.status, resultColor) 60 + } 61 + }) 62 + } 63 + } 64 + 65 + func TestFormatStatusIndicator(t *testing.T) { 66 + testCases := []string{ 67 + models.StatusTodo, 68 + models.StatusInProgress, 69 + models.StatusBlocked, 70 + models.StatusDone, 71 + models.StatusAbandoned, 72 + } 73 + 74 + for _, status := range testCases { 75 + t.Run("format_indicator_"+status, func(t *testing.T) { 76 + result := FormatStatusIndicator(status) 77 + expectedSymbol := GetStatusSymbol(status) 78 + 79 + if !strings.Contains(result, expectedSymbol) { 80 + t.Errorf("Expected formatted indicator for %s to contain symbol %s", status, expectedSymbol) 81 + } 82 + 83 + if result == "" { 84 + t.Errorf("Expected non-empty formatted indicator for status %s", status) 85 + } 86 + }) 87 + } 88 + } 89 + 90 + func TestFormatStatusWithText(t *testing.T) { 91 + testCases := []string{ 92 + models.StatusTodo, 93 + models.StatusInProgress, 94 + models.StatusBlocked, 95 + models.StatusDone, 96 + models.StatusAbandoned, 97 + } 98 + 99 + for _, status := range testCases { 100 + t.Run("format_with_text_"+status, func(t *testing.T) { 101 + result := FormatStatusWithText(status) 102 + expectedSymbol := GetStatusSymbol(status) 103 + 104 + if !strings.Contains(result, expectedSymbol) { 105 + t.Errorf("Expected formatted status for %s to contain symbol %s", status, expectedSymbol) 106 + } 107 + if !strings.Contains(result, status) { 108 + t.Errorf("Expected formatted status for %s to contain status text", status) 109 + } 110 + 111 + if result == "" { 112 + t.Errorf("Expected non-empty formatted status for %s", status) 113 + } 114 + }) 115 + } 116 + } 117 + 118 + func TestGetStatusDescription(t *testing.T) { 119 + testCases := []struct { 120 + status string 121 + description string 122 + }{ 123 + {models.StatusTodo, "Ready to start"}, 124 + {models.StatusInProgress, "Currently working"}, 125 + {models.StatusBlocked, "Waiting on dependency"}, 126 + {models.StatusDone, "Completed successfully"}, 127 + {models.StatusAbandoned, "No longer relevant"}, 128 + {models.StatusPending, "Pending (legacy)"}, 129 + {models.StatusCompleted, "Completed (legacy)"}, 130 + {models.StatusDeleted, "Deleted (legacy)"}, 131 + {"unknown", "Unknown status"}, 132 + } 133 + 134 + for _, tc := range testCases { 135 + t.Run("description_"+tc.status, func(t *testing.T) { 136 + result := GetStatusDescription(tc.status) 137 + if result != tc.description { 138 + t.Errorf("Expected description %s for status %s, got %s", tc.description, tc.status, result) 139 + } 140 + }) 141 + } 142 + } 143 + 144 + func TestFormatTaskStatus(t *testing.T) { 145 + t.Run("nil_task", func(t *testing.T) { 146 + result := FormatTaskStatus(nil) 147 + if result != "" { 148 + t.Errorf("Expected empty string for nil task, got %s", result) 149 + } 150 + }) 151 + 152 + testCases := []string{ 153 + models.StatusTodo, 154 + models.StatusInProgress, 155 + models.StatusBlocked, 156 + models.StatusDone, 157 + models.StatusAbandoned, 158 + } 159 + 160 + for _, status := range testCases { 161 + t.Run("format_task_status_"+status, func(t *testing.T) { 162 + task := &models.Task{ 163 + ID: 1, 164 + Status: status, 165 + } 166 + 167 + result := FormatTaskStatus(task) 168 + expectedSymbol := GetStatusSymbol(status) 169 + expectedDescription := GetStatusDescription(status) 170 + 171 + if !strings.Contains(result, expectedSymbol) { 172 + t.Errorf("Expected task status format to contain symbol %s", expectedSymbol) 173 + } 174 + if !strings.Contains(result, status) { 175 + t.Errorf("Expected task status format to contain status %s", status) 176 + } 177 + if !strings.Contains(result, expectedDescription) { 178 + t.Errorf("Expected task status format to contain description %s", expectedDescription) 179 + } 180 + 181 + if result == "" { 182 + t.Errorf("Expected non-empty formatted task status for %s", status) 183 + } 184 + }) 185 + } 186 + } 187 + 188 + func TestStatusLegend(t *testing.T) { 189 + result := StatusLegend() 190 + 191 + if result == "" { 192 + t.Error("Expected non-empty status legend") 193 + } 194 + 195 + expectedStatuses := []string{ 196 + models.StatusTodo, 197 + models.StatusInProgress, 198 + models.StatusBlocked, 199 + models.StatusDone, 200 + models.StatusAbandoned, 201 + } 202 + 203 + for _, status := range expectedStatuses { 204 + expectedSymbol := GetStatusSymbol(status) 205 + if !strings.Contains(result, expectedSymbol) { 206 + t.Errorf("Expected legend to contain symbol %s for status %s", expectedSymbol, status) 207 + } 208 + if !strings.Contains(result, status) { 209 + t.Errorf("Expected legend to contain status text %s", status) 210 + } 211 + } 212 + } 213 + 214 + func TestGetAllStatusSymbols(t *testing.T) { 215 + symbols := GetAllStatusSymbols() 216 + 217 + expectedSymbols := map[string]string{ 218 + models.StatusTodo: StatusTodoSymbol, 219 + models.StatusInProgress: StatusInProgressSymbol, 220 + models.StatusBlocked: StatusBlockedSymbol, 221 + models.StatusDone: StatusDoneSymbol, 222 + models.StatusAbandoned: StatusAbandonedSymbol, 223 + models.StatusPending: StatusPendingSymbol, 224 + models.StatusCompleted: StatusCompletedSymbol, 225 + models.StatusDeleted: StatusDeletedSymbol, 226 + } 227 + 228 + if len(symbols) != len(expectedSymbols) { 229 + t.Errorf("Expected %d status symbols, got %d", len(expectedSymbols), len(symbols)) 230 + } 231 + 232 + for status, expectedSymbol := range expectedSymbols { 233 + if symbol, exists := symbols[status]; !exists { 234 + t.Errorf("Expected status %s to exist in symbols map", status) 235 + } else if symbol != expectedSymbol { 236 + t.Errorf("Expected symbol %s for status %s, got %s", expectedSymbol, status, symbol) 237 + } 238 + } 239 + } 240 + 241 + func TestIsValidStatusTransition(t *testing.T) { 242 + testCases := []struct { 243 + from string 244 + to string 245 + expected bool 246 + }{ 247 + {models.StatusTodo, models.StatusInProgress, true}, 248 + {models.StatusTodo, models.StatusBlocked, true}, 249 + {models.StatusTodo, models.StatusDone, true}, 250 + {models.StatusTodo, models.StatusAbandoned, true}, 251 + {models.StatusTodo, models.StatusTodo, false}, 252 + 253 + {models.StatusInProgress, models.StatusTodo, true}, 254 + {models.StatusInProgress, models.StatusBlocked, true}, 255 + {models.StatusInProgress, models.StatusDone, true}, 256 + {models.StatusInProgress, models.StatusAbandoned, true}, 257 + {models.StatusInProgress, models.StatusInProgress, false}, 258 + 259 + {models.StatusBlocked, models.StatusTodo, true}, 260 + {models.StatusBlocked, models.StatusInProgress, true}, 261 + {models.StatusBlocked, models.StatusDone, true}, 262 + {models.StatusBlocked, models.StatusAbandoned, true}, 263 + {models.StatusBlocked, models.StatusBlocked, false}, 264 + 265 + {models.StatusDone, models.StatusTodo, true}, 266 + {models.StatusDone, models.StatusInProgress, true}, 267 + {models.StatusDone, models.StatusBlocked, false}, 268 + {models.StatusDone, models.StatusAbandoned, false}, 269 + {models.StatusDone, models.StatusDone, false}, 270 + 271 + {models.StatusAbandoned, models.StatusTodo, true}, 272 + {models.StatusAbandoned, models.StatusInProgress, true}, 273 + {models.StatusAbandoned, models.StatusBlocked, false}, 274 + {models.StatusAbandoned, models.StatusDone, false}, 275 + {models.StatusAbandoned, models.StatusAbandoned, false}, 276 + 277 + {models.StatusPending, models.StatusTodo, true}, 278 + {models.StatusPending, models.StatusInProgress, true}, 279 + {models.StatusPending, models.StatusBlocked, false}, 280 + {models.StatusCompleted, models.StatusDone, true}, 281 + {models.StatusCompleted, models.StatusTodo, false}, 282 + 283 + {"unknown", models.StatusTodo, false}, 284 + {models.StatusTodo, "unknown", false}, 285 + } 286 + 287 + for _, tc := range testCases { 288 + t.Run("transition_"+tc.from+"_to_"+tc.to, func(t *testing.T) { 289 + result := IsValidStatusTransition(tc.from, tc.to) 290 + if result != tc.expected { 291 + t.Errorf("Expected transition from %s to %s to be %v, got %v", 292 + tc.from, tc.to, tc.expected, result) 293 + } 294 + }) 295 + } 296 + } 297 + 298 + func TestUnicodeSymbolConstants(t *testing.T) { 299 + symbols := []struct { 300 + name string 301 + symbol string 302 + code string 303 + }{ 304 + {"TodoSymbol", StatusTodoSymbol, "โ—"}, 305 + {"InProgressSymbol", StatusInProgressSymbol, "โ—"}, 306 + {"BlockedSymbol", StatusBlockedSymbol, "โ– "}, 307 + {"DoneSymbol", StatusDoneSymbol, "โœ“"}, 308 + {"AbandonedSymbol", StatusAbandonedSymbol, "โšซ"}, 309 + {"PendingSymbol", StatusPendingSymbol, "โ—‹"}, 310 + {"CompletedSymbol", StatusCompletedSymbol, "โœ“"}, 311 + {"DeletedSymbol", StatusDeletedSymbol, "โœ—"}, 312 + } 313 + 314 + for _, s := range symbols { 315 + t.Run("symbol_"+s.name, func(t *testing.T) { 316 + if s.symbol != s.code { 317 + t.Errorf("Expected %s to be %s, got %s", s.name, s.code, s.symbol) 318 + } 319 + }) 320 + } 321 + 322 + symbolMap := make(map[string][]string) 323 + for _, s := range symbols { 324 + symbolMap[s.symbol] = append(symbolMap[s.symbol], s.name) 325 + } 326 + 327 + for symbol, names := range symbolMap { 328 + if len(names) > 1 && symbol != "โœ“" { 329 + t.Errorf("Symbol %s is used by multiple constants: %v", symbol, names) 330 + } 331 + } 332 + } 333 + 334 + func TestStyleConstants(t *testing.T) { 335 + styles := []struct { 336 + name string 337 + style lipgloss.Style 338 + }{ 339 + {"TodoStyle", TodoStyle}, 340 + {"InProgressStyle", InProgressStyle}, 341 + {"BlockedStyle", BlockedStyle}, 342 + {"DoneStyle", DoneStyle}, 343 + {"AbandonedStyle", AbandonedStyle}, 344 + {"PendingStyle", PendingStyle}, 345 + {"CompletedStyle", CompletedStyle}, 346 + {"DeletedStyle", DeletedStyle}, 347 + } 348 + 349 + for _, s := range styles { 350 + t.Run("style_"+s.name, func(t *testing.T) { 351 + testText := "test" 352 + rendered := s.style.Render(testText) 353 + // In test environment without terminal, just check that rendering produces output 354 + if rendered == "" { 355 + t.Errorf("Expected %s to produce output when rendering text", s.name) 356 + } 357 + }) 358 + } 359 + } 360 + 361 + // Priority UI Tests 362 + 363 + func TestGetPrioritySymbol(t *testing.T) { 364 + testCases := []struct { 365 + priority string 366 + expected string 367 + }{ 368 + {models.PriorityHigh, PriorityHighSymbol}, 369 + {models.PriorityMedium, PriorityMediumSymbol}, 370 + {models.PriorityLow, PriorityLowSymbol}, 371 + {"", PriorityNoneSymbol}, 372 + {"A", PriorityHighSymbol}, 373 + {"Z", PriorityHighSymbol}, 374 + {"1", PriorityLowSymbol}, 375 + {"2", PriorityMediumSymbol}, 376 + {"3", PriorityMediumSymbol}, 377 + {"4", PriorityHighSymbol}, 378 + {"5", PriorityHighSymbol}, 379 + {"unknown", PriorityNoneSymbol}, 380 + } 381 + 382 + for _, tc := range testCases { 383 + t.Run("priority_symbol_"+tc.priority, func(t *testing.T) { 384 + result := GetPrioritySymbol(tc.priority) 385 + if result != tc.expected { 386 + t.Errorf("Expected symbol %s for priority %s, got %s", tc.expected, tc.priority, result) 387 + } 388 + }) 389 + } 390 + } 391 + 392 + func TestGetPriorityPattern(t *testing.T) { 393 + testCases := []struct { 394 + priority string 395 + expected string 396 + }{ 397 + {models.PriorityHigh, PriorityHighPattern}, 398 + {models.PriorityMedium, PriorityMediumPattern}, 399 + {models.PriorityLow, PriorityLowPattern}, 400 + {"", PriorityNonePattern}, 401 + {"A", PriorityHighPattern}, 402 + {"C", PriorityHighPattern}, 403 + {"M", PriorityMediumPattern}, 404 + {"Z", PriorityLowPattern}, 405 + {"1", PriorityLowPattern}, 406 + {"2", PriorityMediumPattern}, 407 + {"3", PriorityMediumPattern}, 408 + {"4", PriorityHighPattern}, 409 + {"5", PriorityHighPattern}, 410 + {"unknown", PriorityNonePattern}, 411 + } 412 + 413 + for _, tc := range testCases { 414 + t.Run("priority_pattern_"+tc.priority, func(t *testing.T) { 415 + result := GetPriorityPattern(tc.priority) 416 + if result != tc.expected { 417 + t.Errorf("Expected pattern %s for priority %s, got %s", tc.expected, tc.priority, result) 418 + } 419 + }) 420 + } 421 + } 422 + 423 + func TestGetPriorityStyle(t *testing.T) { 424 + testCases := []struct { 425 + priority string 426 + style lipgloss.Style 427 + }{ 428 + {models.PriorityHigh, PriorityHighStyle}, 429 + {models.PriorityMedium, PriorityMediumStyle}, 430 + {models.PriorityLow, PriorityLowStyle}, 431 + {"", PriorityNoneStyle}, 432 + {"A", PriorityLegacyStyle}, 433 + {"1", PriorityLegacyStyle}, 434 + {"unknown", PriorityNoneStyle}, 435 + } 436 + 437 + for _, tc := range testCases { 438 + t.Run("priority_style_"+tc.priority, func(t *testing.T) { 439 + result := GetPriorityStyle(tc.priority) 440 + expectedColor := tc.style.GetForeground() 441 + resultColor := result.GetForeground() 442 + if expectedColor != resultColor { 443 + t.Errorf("Expected color %s for priority %s, got %s", expectedColor, tc.priority, resultColor) 444 + } 445 + }) 446 + } 447 + } 448 + 449 + func TestFormatPriorityIndicator(t *testing.T) { 450 + testCases := []string{models.PriorityHigh, models.PriorityMedium, models.PriorityLow, "", "A", "1"} 451 + 452 + for _, priority := range testCases { 453 + t.Run("format_priority_indicator_"+priority, func(t *testing.T) { 454 + got := FormatPriorityIndicator(priority) 455 + want := GetPriorityPattern(priority) 456 + 457 + if !strings.Contains(got, want) { 458 + t.Errorf("Expected formatted priority indicator for %s to contain pattern %s", priority, want) 459 + } 460 + 461 + if got == "" { 462 + t.Errorf("Expected non-empty formatted priority indicator for priority %s", priority) 463 + } 464 + }) 465 + } 466 + } 467 + 468 + func TestFormatPriorityWithText(t *testing.T) { 469 + testCases := []struct { 470 + priority string 471 + shouldContain []string 472 + }{ 473 + {models.PriorityHigh, []string{PriorityHighPattern, models.PriorityHigh}}, 474 + {models.PriorityMedium, []string{PriorityMediumPattern, models.PriorityMedium}}, 475 + {models.PriorityLow, []string{PriorityLowPattern, models.PriorityLow}}, 476 + {"", []string{PriorityNonePattern, "None"}}, 477 + {"A", []string{PriorityHighPattern, "A"}}, 478 + {"1", []string{PriorityLowPattern, "1"}}, 479 + } 480 + 481 + for _, tc := range testCases { 482 + t.Run("format_priority_with_text_"+tc.priority, func(t *testing.T) { 483 + got := FormatPriorityWithText(tc.priority) 484 + 485 + for _, want := range tc.shouldContain { 486 + if !strings.Contains(got, want) { 487 + t.Errorf("Expected formatted priority for %s to contain %s, got %s", tc.priority, want, got) 488 + } 489 + } 490 + 491 + if got == "" { 492 + t.Errorf("Expected non-empty formatted priority for %s", tc.priority) 493 + } 494 + }) 495 + } 496 + } 497 + 498 + func TestGetPriorityDescription(t *testing.T) { 499 + testCases := []struct { 500 + priority string 501 + description string 502 + }{ 503 + {models.PriorityHigh, "Urgent - do first"}, 504 + {models.PriorityMedium, "Important - schedule soon"}, 505 + {models.PriorityLow, "Nice to have - when time permits"}, 506 + {"", "No priority set"}, 507 + {"A", "Priority A (legacy)"}, 508 + {"Z", "Priority Z (legacy)"}, 509 + {"1", "Priority 1 (lowest)"}, 510 + {"2", "Priority 2 (low)"}, 511 + {"3", "Priority 3 (medium)"}, 512 + {"4", "Priority 4 (high)"}, 513 + {"5", "Priority 5 (highest)"}, 514 + {"unknown", "Unknown priority"}, 515 + } 516 + 517 + for _, tc := range testCases { 518 + t.Run("priority_description_"+tc.priority, func(t *testing.T) { 519 + got := GetPriorityDescription(tc.priority) 520 + want := tc.description 521 + if got != want { 522 + t.Errorf("Expected description %s for priority %s, got %s", want, tc.priority, got) 523 + } 524 + }) 525 + } 526 + } 527 + 528 + func TestFormatTaskPriority(t *testing.T) { 529 + t.Run("nil_task", func(t *testing.T) { 530 + got := FormatTaskPriority(nil) 531 + if got != "" { 532 + t.Errorf("Expected empty string for nil task, got %s", got) 533 + } 534 + }) 535 + 536 + testCases := []struct { 537 + priority string 538 + shouldContain []string 539 + }{ 540 + {models.PriorityHigh, []string{PriorityHighPattern, models.PriorityHigh, "Urgent - do first"}}, 541 + {models.PriorityMedium, []string{PriorityMediumPattern, models.PriorityMedium, "Important - schedule soon"}}, 542 + {models.PriorityLow, []string{PriorityLowPattern, models.PriorityLow, "Nice to have - when time permits"}}, 543 + {"", []string{PriorityNonePattern, "No priority set"}}, 544 + {"A", []string{PriorityHighPattern, "A", "Priority A (legacy)"}}, 545 + {"1", []string{PriorityLowPattern, "1", "Priority 1 (lowest)"}}, 546 + } 547 + 548 + for _, tc := range testCases { 549 + t.Run("format_task_priority_"+tc.priority, func(t *testing.T) { 550 + task := &models.Task{ID: 1, Priority: tc.priority} 551 + got := FormatTaskPriority(task) 552 + 553 + for _, want := range tc.shouldContain { 554 + if !strings.Contains(got, want) { 555 + t.Errorf("Expected task priority format to contain %s, got %s", want, got) 556 + } 557 + } 558 + 559 + if got == "" { 560 + t.Errorf("Expected non-empty formatted task priority for %s", tc.priority) 561 + } 562 + }) 563 + } 564 + } 565 + 566 + func TestPriorityLegend(t *testing.T) { 567 + got := PriorityLegend() 568 + 569 + if got == "" { 570 + t.Error("Expected non-empty priority legend") 571 + } 572 + 573 + expectedPriorities := []string{models.PriorityHigh, models.PriorityMedium, models.PriorityLow, "None"} 574 + expectedPatterns := []string{PriorityHighPattern, PriorityMediumPattern, PriorityLowPattern, PriorityNonePattern} 575 + 576 + for _, want := range expectedPriorities { 577 + if !strings.Contains(got, want) { 578 + t.Errorf("Expected legend to contain priority text %s", want) 579 + } 580 + } 581 + 582 + for _, want := range expectedPatterns { 583 + if !strings.Contains(got, want) { 584 + t.Errorf("Expected legend to contain pattern %s", want) 585 + } 586 + } 587 + } 588 + 589 + func TestGetAllPrioritySymbols(t *testing.T) { 590 + symbols := GetAllPrioritySymbols() 591 + 592 + expectedSymbols := map[string]string{ 593 + models.PriorityHigh: PriorityHighSymbol, 594 + models.PriorityMedium: PriorityMediumSymbol, 595 + models.PriorityLow: PriorityLowSymbol, 596 + "": PriorityNoneSymbol, 597 + } 598 + 599 + if len(symbols) != len(expectedSymbols) { 600 + t.Errorf("Expected %d priority symbols, got %d", len(expectedSymbols), len(symbols)) 601 + } 602 + 603 + for priority, expectedSymbol := range expectedSymbols { 604 + if symbol, exists := symbols[priority]; !exists { 605 + t.Errorf("Expected priority %s to exist in symbols map", priority) 606 + } else if symbol != expectedSymbol { 607 + t.Errorf("Expected symbol %s for priority %s, got %s", expectedSymbol, priority, symbol) 608 + } 609 + } 610 + } 611 + 612 + func TestGetAllPriorityPatterns(t *testing.T) { 613 + patterns := GetAllPriorityPatterns() 614 + 615 + expectedPatterns := map[string]string{ 616 + models.PriorityHigh: PriorityHighPattern, 617 + models.PriorityMedium: PriorityMediumPattern, 618 + models.PriorityLow: PriorityLowPattern, 619 + "": PriorityNonePattern, 620 + } 621 + 622 + if len(patterns) != len(expectedPatterns) { 623 + t.Errorf("Expected %d priority patterns, got %d", len(expectedPatterns), len(patterns)) 624 + } 625 + 626 + for priority, want := range expectedPatterns { 627 + if got, exists := patterns[priority]; !exists { 628 + t.Errorf("Expected priority %s to exist in patterns map", priority) 629 + } else if got != want { 630 + t.Errorf("Expected pattern %s for priority %s, got %s", want, priority, got) 631 + } 632 + } 633 + } 634 + 635 + func TestGetPriorityDisplayType(t *testing.T) { 636 + testCases := []struct { 637 + priority string 638 + expected string 639 + }{ 640 + {models.PriorityHigh, "text"}, 641 + {models.PriorityMedium, "text"}, 642 + {models.PriorityLow, "text"}, 643 + {"1", "numeric"}, 644 + {"2", "numeric"}, 645 + {"3", "numeric"}, 646 + {"4", "numeric"}, 647 + {"5", "numeric"}, 648 + {"A", "legacy"}, 649 + {"Z", "legacy"}, 650 + {"", "none"}, 651 + {"unknown", "unknown"}, 652 + } 653 + 654 + for _, tc := range testCases { 655 + t.Run("priority_display_type_"+tc.priority, func(t *testing.T) { 656 + got := GetPriorityDisplayType(tc.priority) 657 + if got != tc.expected { 658 + t.Errorf("Expected display type %s for priority %s, got %s", tc.expected, tc.priority, got) 659 + } 660 + }) 661 + } 662 + } 663 + 664 + func TestPriorityUnicodeSymbolConstants(t *testing.T) { 665 + symbols := []struct { 666 + name string 667 + symbol string 668 + code string 669 + }{ 670 + {"PriorityHighSymbol", PriorityHighSymbol, "โ˜…"}, 671 + {"PriorityMediumSymbol", PriorityMediumSymbol, "โ˜†"}, 672 + {"PriorityLowSymbol", PriorityLowSymbol, "โ—ฆ"}, 673 + {"PriorityNoneSymbol", PriorityNoneSymbol, "โ—‹"}, 674 + } 675 + 676 + for _, s := range symbols { 677 + t.Run("priority_symbol_"+s.name, func(t *testing.T) { 678 + if s.symbol != s.code { 679 + t.Errorf("Expected %s to be %s, got %s", s.name, s.code, s.symbol) 680 + } 681 + }) 682 + } 683 + } 684 + 685 + func TestPriorityPatternConstants(t *testing.T) { 686 + patterns := []struct { 687 + name string 688 + pattern string 689 + code string 690 + }{ 691 + {"PriorityHighPattern", PriorityHighPattern, "โ˜…โ˜…โ˜…"}, 692 + {"PriorityMediumPattern", PriorityMediumPattern, "โ˜…โ˜…โ˜†"}, 693 + {"PriorityLowPattern", PriorityLowPattern, "โ˜…โ˜†โ˜†"}, 694 + {"PriorityNonePattern", PriorityNonePattern, "โ˜†โ˜†โ˜†"}, 695 + } 696 + 697 + for _, p := range patterns { 698 + t.Run("priority_pattern_"+p.name, func(t *testing.T) { 699 + if p.pattern != p.code { 700 + t.Errorf("Expected %s to be %s, got %s", p.name, p.code, p.pattern) 701 + } 702 + }) 703 + } 704 + } 705 + 706 + func TestPriorityStyleConstants(t *testing.T) { 707 + styles := []struct { 708 + name string 709 + style lipgloss.Style 710 + }{ 711 + {"PriorityHighStyle", PriorityHighStyle}, 712 + {"PriorityMediumStyle", PriorityMediumStyle}, 713 + {"PriorityLowStyle", PriorityLowStyle}, 714 + {"PriorityNoneStyle", PriorityNoneStyle}, 715 + {"PriorityLegacyStyle", PriorityLegacyStyle}, 716 + } 717 + 718 + for _, s := range styles { 719 + t.Run("priority_style_"+s.name, func(t *testing.T) { 720 + testText := "test" 721 + rendered := s.style.Render(testText) 722 + if rendered == "" { 723 + t.Errorf("Expected %s to produce output when rendering text", s.name) 724 + } 725 + }) 726 + } 727 + }