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

refactor: project & tag adapters

+658 -1343
+4 -5
internal/handlers/tasks.go
··· 552 } 553 554 func (h *TaskHandler) listProjectsInteractive(ctx context.Context) error { 555 - projectList := ui.NewProjectList(h.repos.Tasks, ui.ProjectListOptions{}) 556 - return projectList.Browse(ctx) 557 } 558 559 // ListTags lists all tags with their task counts ··· 645 } 646 647 func (h *TaskHandler) listTagsInteractive(ctx context.Context) error { 648 - tagList := ui.NewTagList(h.repos.Tasks, ui.TagListOptions{}) 649 - return tagList.Browse(ctx) 650 } 651 652 func (h *TaskHandler) printTask(task *models.Task) { ··· 755 } 756 } 757 758 - // formatDuration formats a duration in a human-readable format 759 func formatDuration(d time.Duration) string { 760 if d < time.Minute { 761 return fmt.Sprintf("%.0fs", d.Seconds())
··· 552 } 553 554 func (h *TaskHandler) listProjectsInteractive(ctx context.Context) error { 555 + projectTable := ui.NewProjectListFromTable(h.repos.Tasks, nil, nil, false) 556 + return projectTable.Browse(ctx) 557 } 558 559 // ListTags lists all tags with their task counts ··· 645 } 646 647 func (h *TaskHandler) listTagsInteractive(ctx context.Context) error { 648 + tagTable := ui.NewTagListFromTable(h.repos.Tasks, nil, nil, false) 649 + return tagTable.Browse(ctx) 650 } 651 652 func (h *TaskHandler) printTask(task *models.Task) { ··· 755 } 756 } 757 758 func formatDuration(d time.Duration) string { 759 if d < time.Minute { 760 return fmt.Sprintf("%.0fs", d.Seconds())
-2
internal/ui/data_list.go
··· 154 listViewMsg string 155 listErrorMsg error 156 listCountMsg int 157 - searchModeMsg bool 158 ) 159 160 type dataListModel struct { ··· 172 help help.Model 173 showingHelp bool 174 totalCount int 175 - currentPage int 176 listOpts ListOptions 177 } 178
··· 154 listViewMsg string 155 listErrorMsg error 156 listCountMsg int 157 ) 158 159 type dataListModel struct { ··· 171 help help.Model 172 showingHelp bool 173 totalCount int 174 listOpts ListOptions 175 } 176
+6 -13
internal/ui/data_list_test.go
··· 15 ) 16 17 type MockListItem struct { 18 - id int64 19 title string 20 description string 21 filterValue string 22 - created time.Time 23 - modified time.Time 24 } 25 26 func (m MockListItem) GetID() int64 { 27 - return m.id 28 } 29 30 func (m MockListItem) SetID(id int64) { 31 - m.id = id 32 } 33 34 func (m MockListItem) GetTableName() string { ··· 36 } 37 38 func (m MockListItem) GetCreatedAt() time.Time { 39 - return m.created 40 } 41 42 func (m MockListItem) SetCreatedAt(t time.Time) { 43 - m.created = t 44 } 45 46 func (m MockListItem) GetUpdatedAt() time.Time { 47 - return m.modified 48 } 49 50 func (m MockListItem) SetUpdatedAt(t time.Time) { 51 - m.modified = t 52 } 53 54 func (m MockListItem) GetTitle() string { ··· 64 } 65 66 func NewMockItem(id int64, title, description, filterValue string) MockListItem { 67 - now := time.Now() 68 return MockListItem{ 69 - id: id, 70 title: title, 71 description: description, 72 filterValue: filterValue, 73 - created: now, 74 - modified: now, 75 } 76 } 77
··· 15 ) 16 17 type MockListItem struct { 18 title string 19 description string 20 filterValue string 21 } 22 23 func (m MockListItem) GetID() int64 { 24 + return 1 // Mock ID 25 } 26 27 func (m MockListItem) SetID(id int64) { 28 + // Mock - no-op 29 } 30 31 func (m MockListItem) GetTableName() string { ··· 33 } 34 35 func (m MockListItem) GetCreatedAt() time.Time { 36 + return time.Time{} // Mock - zero time 37 } 38 39 func (m MockListItem) SetCreatedAt(t time.Time) { 40 + // Mock - no-op 41 } 42 43 func (m MockListItem) GetUpdatedAt() time.Time { 44 + return time.Time{} // Mock - zero time 45 } 46 47 func (m MockListItem) SetUpdatedAt(t time.Time) { 48 + // Mock - no-op 49 } 50 51 func (m MockListItem) GetTitle() string { ··· 61 } 62 63 func NewMockItem(id int64, title, description, filterValue string) MockListItem { 64 return MockListItem{ 65 title: title, 66 description: description, 67 filterValue: filterValue, 68 } 69 } 70
-1
internal/ui/data_table.go
··· 166 help help.Model 167 showingHelp bool 168 totalCount int 169 - currentPage int 170 dataOpts DataOptions 171 } 172
··· 166 help help.Model 167 showingHelp bool 168 totalCount int 169 dataOpts DataOptions 170 } 171
+8 -16
internal/ui/data_table_test.go
··· 15 ) 16 17 type MockDataRecord struct { 18 - id int64 19 - fields map[string]any 20 - created time.Time 21 - modified time.Time 22 } 23 24 func (m MockDataRecord) GetID() int64 { 25 - return m.id 26 } 27 28 func (m MockDataRecord) SetID(id int64) { 29 - m.id = id 30 } 31 32 func (m MockDataRecord) GetTableName() string { ··· 34 } 35 36 func (m MockDataRecord) GetCreatedAt() time.Time { 37 - return m.created 38 } 39 40 func (m MockDataRecord) SetCreatedAt(t time.Time) { 41 - m.created = t 42 } 43 44 func (m MockDataRecord) GetUpdatedAt() time.Time { 45 - return m.modified 46 } 47 48 func (m MockDataRecord) SetUpdatedAt(t time.Time) { 49 - m.modified = t 50 } 51 52 func (m MockDataRecord) GetField(name string) any { ··· 54 } 55 56 func NewMockRecord(id int64, fields map[string]any) MockDataRecord { 57 - now := time.Now() 58 return MockDataRecord{ 59 - id: id, 60 - fields: fields, 61 - created: now, 62 - modified: now, 63 } 64 } 65 ··· 67 records []DataRecord 68 loadError error 69 countError error 70 - loadDelay bool 71 } 72 73 func (m *MockDataSource) Load(ctx context.Context, opts DataOptions) ([]DataRecord, error) {
··· 15 ) 16 17 type MockDataRecord struct { 18 + fields map[string]any 19 } 20 21 func (m MockDataRecord) GetID() int64 { 22 + return 1 // Mock ID 23 } 24 25 func (m MockDataRecord) SetID(id int64) { 26 + // Mock - no-op 27 } 28 29 func (m MockDataRecord) GetTableName() string { ··· 31 } 32 33 func (m MockDataRecord) GetCreatedAt() time.Time { 34 + return time.Time{} // Mock - zero time 35 } 36 37 func (m MockDataRecord) SetCreatedAt(t time.Time) { 38 + // Mock - no-op 39 } 40 41 func (m MockDataRecord) GetUpdatedAt() time.Time { 42 + return time.Time{} // Mock - zero time 43 } 44 45 func (m MockDataRecord) SetUpdatedAt(t time.Time) { 46 + // Mock - no-op 47 } 48 49 func (m MockDataRecord) GetField(name string) any { ··· 51 } 52 53 func NewMockRecord(id int64, fields map[string]any) MockDataRecord { 54 return MockDataRecord{ 55 + fields: fields, 56 } 57 } 58 ··· 60 records []DataRecord 61 loadError error 62 countError error 63 } 64 65 func (m *MockDataSource) Load(ctx context.Context, opts DataOptions) ([]DataRecord, error) {
-290
internal/ui/project_list.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 - tea "github.com/charmbracelet/bubbletea" 13 - "github.com/charmbracelet/lipgloss" 14 - "github.com/stormlightlabs/noteleaf/internal/repo" 15 - ) 16 - 17 - // Project list key bindings 18 - type projectKeyMap struct { 19 - Up key.Binding 20 - Down key.Binding 21 - Enter key.Binding 22 - Refresh key.Binding 23 - Quit key.Binding 24 - Back key.Binding 25 - Help key.Binding 26 - Numbers []key.Binding 27 - } 28 - 29 - func (k projectKeyMap) ShortHelp() []key.Binding { 30 - return []key.Binding{k.Up, k.Down, k.Enter, k.Help, k.Quit} 31 - } 32 - 33 - func (k projectKeyMap) FullHelp() [][]key.Binding { 34 - return [][]key.Binding{ 35 - {k.Up, k.Down, k.Enter, k.Refresh}, 36 - {k.Help, k.Quit, k.Back}, 37 - } 38 - } 39 - 40 - var projectKeys = projectKeyMap{ 41 - Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("โ†‘/k", "move up")), 42 - Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("โ†“/j", "move down")), 43 - Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select project")), 44 - Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), 45 - Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), 46 - Back: key.NewBinding(key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back")), 47 - Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), 48 - Numbers: []key.Binding{ 49 - key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "jump to project 1")), 50 - key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "jump to project 2")), 51 - key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "jump to project 3")), 52 - key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "jump to project 4")), 53 - key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "jump to project 5")), 54 - key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "jump to project 6")), 55 - key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "jump to project 7")), 56 - key.NewBinding(key.WithKeys("8"), key.WithHelp("8", "jump to project 8")), 57 - key.NewBinding(key.WithKeys("9"), key.WithHelp("9", "jump to project 9")), 58 - }, 59 - } 60 - 61 - // ProjectRepository interface for dependency injection in tests 62 - type ProjectRepository interface { 63 - GetProjects(ctx context.Context) ([]repo.ProjectSummary, error) 64 - } 65 - 66 - // ProjectListOptions configures the project list UI behavior 67 - type ProjectListOptions struct { 68 - // Output destination (stdout for interactive, buffer for testing) 69 - Output io.Writer 70 - // Input source (stdin for interactive, strings reader for testing) 71 - Input io.Reader 72 - // Enable static mode (no interactive components) 73 - Static bool 74 - } 75 - 76 - // ProjectList handles project browsing UI 77 - type ProjectList struct { 78 - repo ProjectRepository 79 - opts ProjectListOptions 80 - } 81 - 82 - // NewProjectList creates a new project list UI component 83 - func NewProjectList(repo ProjectRepository, opts ProjectListOptions) *ProjectList { 84 - if opts.Output == nil { 85 - opts.Output = os.Stdout 86 - } 87 - if opts.Input == nil { 88 - opts.Input = os.Stdin 89 - } 90 - return &ProjectList{repo: repo, opts: opts} 91 - } 92 - 93 - type ( 94 - projectsLoadedMsg []repo.ProjectSummary 95 - errorProjectMsg error 96 - projectListModel struct { 97 - projects []repo.ProjectSummary 98 - selected int 99 - err error 100 - repo ProjectRepository 101 - opts ProjectListOptions 102 - keys projectKeyMap 103 - help help.Model 104 - showingHelp bool 105 - } 106 - ) 107 - 108 - func (m projectListModel) Init() tea.Cmd { 109 - return m.loadProjects() 110 - } 111 - 112 - func (m projectListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 113 - switch msg := msg.(type) { 114 - case tea.KeyMsg: 115 - if m.showingHelp { 116 - switch { 117 - case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Help): 118 - m.showingHelp = false 119 - return m, nil 120 - } 121 - return m, nil 122 - } 123 - 124 - switch { 125 - case key.Matches(msg, m.keys.Quit): 126 - return m, tea.Quit 127 - case key.Matches(msg, m.keys.Up): 128 - if m.selected > 0 { 129 - m.selected-- 130 - } 131 - case key.Matches(msg, m.keys.Down): 132 - if m.selected < len(m.projects)-1 { 133 - m.selected++ 134 - } 135 - case key.Matches(msg, m.keys.Enter): 136 - if len(m.projects) > 0 && m.selected < len(m.projects) { 137 - // TODO: navigate to tasks for that project 138 - return m, tea.Quit 139 - } 140 - case key.Matches(msg, m.keys.Refresh): 141 - return m, m.loadProjects() 142 - case key.Matches(msg, m.keys.Help): 143 - m.showingHelp = true 144 - return m, nil 145 - default: 146 - for i, numKey := range m.keys.Numbers { 147 - if key.Matches(msg, numKey) && i < len(m.projects) { 148 - m.selected = i 149 - break 150 - } 151 - } 152 - } 153 - case projectsLoadedMsg: 154 - m.projects = []repo.ProjectSummary(msg) 155 - if m.selected >= len(m.projects) && len(m.projects) > 0 { 156 - m.selected = len(m.projects) - 1 157 - } 158 - case errorProjectMsg: 159 - m.err = error(msg) 160 - } 161 - return m, nil 162 - } 163 - 164 - func (m projectListModel) View() string { 165 - var s strings.Builder 166 - 167 - style := lipgloss.NewStyle().Foreground(lipgloss.Color(Squid.Hex())) 168 - 169 - if m.showingHelp { 170 - return m.help.View(m.keys) 171 - } 172 - 173 - s.WriteString(TitleColorStyle.Render("Projects")) 174 - s.WriteString("\n\n") 175 - 176 - if m.err != nil { 177 - s.WriteString(fmt.Sprintf("Error: %s", m.err)) 178 - return s.String() 179 - } 180 - 181 - if len(m.projects) == 0 { 182 - s.WriteString("No projects found") 183 - s.WriteString("\n\n") 184 - s.WriteString(style.Render("Press r to refresh, q to quit")) 185 - return s.String() 186 - } 187 - 188 - headerLine := fmt.Sprintf(" %-30s %-15s", "Project Name", "Task Count") 189 - s.WriteString(HeaderColorStyle.Render(headerLine)) 190 - s.WriteString("\n") 191 - s.WriteString(HeaderColorStyle.Render(strings.Repeat("โ”€", 50))) 192 - s.WriteString("\n") 193 - 194 - for i, project := range m.projects { 195 - prefix := " " 196 - if i == m.selected { 197 - prefix = " > " 198 - } 199 - 200 - projectName := project.Name 201 - if len(projectName) > 28 { 202 - projectName = projectName[:25] + "..." 203 - } 204 - 205 - taskCountStr := fmt.Sprintf("%d task%s", project.TaskCount, pluralizeCount(project.TaskCount)) 206 - 207 - line := fmt.Sprintf("%s%-30s %-15s", prefix, projectName, taskCountStr) 208 - 209 - if i == m.selected { 210 - s.WriteString(SelectedColorStyle.Render(line)) 211 - } else { 212 - s.WriteString(style.Render(line)) 213 - } 214 - 215 - s.WriteString("\n") 216 - } 217 - 218 - s.WriteString("\n") 219 - s.WriteString(m.help.View(m.keys)) 220 - 221 - return s.String() 222 - } 223 - 224 - func (m projectListModel) loadProjects() tea.Cmd { 225 - return func() tea.Msg { 226 - projects, err := m.repo.GetProjects(context.Background()) 227 - if err != nil { 228 - return errorProjectMsg(err) 229 - } 230 - 231 - return projectsLoadedMsg(projects) 232 - } 233 - } 234 - 235 - // Browse opens an interactive TUI for navigating projects 236 - func (pl *ProjectList) Browse(ctx context.Context) error { 237 - if pl.opts.Static { 238 - return pl.staticList(ctx) 239 - } 240 - 241 - model := projectListModel{ 242 - repo: pl.repo, 243 - opts: pl.opts, 244 - keys: projectKeys, 245 - help: help.New(), 246 - } 247 - 248 - program := tea.NewProgram(model, tea.WithInput(pl.opts.Input), tea.WithOutput(pl.opts.Output)) 249 - 250 - _, err := program.Run() 251 - return err 252 - } 253 - 254 - func (pl *ProjectList) staticList(ctx context.Context) error { 255 - projects, err := pl.repo.GetProjects(ctx) 256 - if err != nil { 257 - fmt.Fprintf(pl.opts.Output, "Error: %s\n", err) 258 - return err 259 - } 260 - 261 - fmt.Fprintf(pl.opts.Output, "Projects\n\n") 262 - 263 - if len(projects) == 0 { 264 - fmt.Fprintf(pl.opts.Output, "No projects found\n") 265 - return nil 266 - } 267 - 268 - fmt.Fprintf(pl.opts.Output, "%-30s %-15s\n", "Project Name", "Task Count") 269 - fmt.Fprintf(pl.opts.Output, "%s\n", strings.Repeat("โ”€", 50)) 270 - 271 - for _, project := range projects { 272 - projectName := project.Name 273 - if len(projectName) > 28 { 274 - projectName = projectName[:25] + "..." 275 - } 276 - 277 - taskCountStr := fmt.Sprintf("%d task%s", project.TaskCount, pluralizeCount(project.TaskCount)) 278 - 279 - fmt.Fprintf(pl.opts.Output, "%-30s %-15s\n", projectName, taskCountStr) 280 - } 281 - 282 - return nil 283 - } 284 - 285 - func pluralizeCount(count int) string { 286 - if count == 1 { 287 - return "" 288 - } 289 - return "s" 290 - }
···
+120
internal/ui/project_list_adapter.go
···
··· 1 + package ui 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "time" 8 + 9 + "github.com/stormlightlabs/noteleaf/internal/repo" 10 + ) 11 + 12 + // ProjectRepository interface for dependency injection in tests 13 + type ProjectRepository interface { 14 + GetProjects(ctx context.Context) ([]repo.ProjectSummary, error) 15 + } 16 + 17 + 18 + func pluralizeCount(count int) string { 19 + if count == 1 { 20 + return "" 21 + } 22 + return "s" 23 + } 24 + 25 + // ProjectSummaryRecord adapts repo.ProjectSummary to work with DataTable 26 + type ProjectSummaryRecord struct { 27 + summary repo.ProjectSummary 28 + } 29 + 30 + func (p *ProjectSummaryRecord) GetField(name string) any { 31 + switch name { 32 + case "name": 33 + return p.summary.Name 34 + case "task_count": 35 + return p.summary.TaskCount 36 + default: 37 + return "" 38 + } 39 + } 40 + 41 + func (p *ProjectSummaryRecord) GetTableName() string { 42 + return "projects" 43 + } 44 + 45 + // Use task count as pseudo-ID since projects don't have IDs 46 + func (p *ProjectSummaryRecord) GetID() int64 { return int64(p.summary.TaskCount) } 47 + func (p *ProjectSummaryRecord) SetID(id int64) {} 48 + func (p *ProjectSummaryRecord) GetCreatedAt() time.Time { return time.Time{} } 49 + func (p *ProjectSummaryRecord) SetCreatedAt(t time.Time) {} 50 + func (p *ProjectSummaryRecord) GetUpdatedAt() time.Time { return time.Time{} } 51 + func (p *ProjectSummaryRecord) SetUpdatedAt(t time.Time) {} 52 + 53 + // ProjectDataSource adapts ProjectRepository to work with DataTable 54 + type ProjectDataSource struct { 55 + repo ProjectRepository 56 + } 57 + 58 + func (p *ProjectDataSource) Load(ctx context.Context, opts DataOptions) ([]DataRecord, error) { 59 + projects, err := p.repo.GetProjects(ctx) 60 + if err != nil { 61 + return nil, err 62 + } 63 + 64 + records := make([]DataRecord, len(projects)) 65 + for i, project := range projects { 66 + records[i] = &ProjectSummaryRecord{summary: project} 67 + } 68 + 69 + return records, nil 70 + } 71 + 72 + func (p *ProjectDataSource) Count(ctx context.Context, opts DataOptions) (int, error) { 73 + projects, err := p.repo.GetProjects(ctx) 74 + if err != nil { 75 + return 0, err 76 + } 77 + return len(projects), nil 78 + } 79 + 80 + // NewProjectDataTable creates a new DataTable for browsing projects 81 + func NewProjectDataTable(repo ProjectRepository, opts DataTableOptions) *DataTable { 82 + if opts.Title == "" { 83 + opts.Title = "Projects" 84 + } 85 + 86 + if len(opts.Fields) == 0 { 87 + opts.Fields = []Field{ 88 + { 89 + Name: "name", 90 + Title: "Project Name", 91 + Width: 30, 92 + }, 93 + { 94 + Name: "task_count", 95 + Title: "Task Count", 96 + Width: 15, 97 + Formatter: func(value any) string { 98 + if count, ok := value.(int); ok { 99 + return fmt.Sprintf("%d task%s", count, pluralizeCount(count)) 100 + } 101 + return fmt.Sprintf("%v", value) 102 + }, 103 + }, 104 + } 105 + } 106 + 107 + source := &ProjectDataSource{repo: repo} 108 + return NewDataTable(source, opts) 109 + } 110 + 111 + // NewProjectListFromTable creates a ProjectList-compatible interface using DataTable 112 + func NewProjectListFromTable(repo ProjectRepository, output io.Writer, input io.Reader, static bool) *DataTable { 113 + opts := DataTableOptions{ 114 + Output: output, 115 + Input: input, 116 + Static: static, 117 + Title: "Projects", 118 + } 119 + return NewProjectDataTable(repo, opts) 120 + }
+209
internal/ui/project_list_adapter_test.go
···
··· 1 + package ui 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "strings" 8 + "testing" 9 + 10 + "github.com/stormlightlabs/noteleaf/internal/repo" 11 + ) 12 + 13 + // mockProjectRepository implements ProjectRepository for testing 14 + type mockProjectRepository struct { 15 + projects []repo.ProjectSummary 16 + err error 17 + } 18 + 19 + func (m *mockProjectRepository) GetProjects(ctx context.Context) ([]repo.ProjectSummary, error) { 20 + if m.err != nil { 21 + return nil, m.err 22 + } 23 + return m.projects, nil 24 + } 25 + 26 + func TestProjectAdapter(t *testing.T) { 27 + t.Run("ProjectSummaryRecord", func(t *testing.T) { 28 + summary := repo.ProjectSummary{ 29 + Name: "Test Project", 30 + TaskCount: 5, 31 + } 32 + record := &ProjectSummaryRecord{summary: summary} 33 + 34 + t.Run("GetField", func(t *testing.T) { 35 + tests := []struct { 36 + field string 37 + expected any 38 + name string 39 + }{ 40 + {"name", "Test Project", "should return project name"}, 41 + {"task_count", 5, "should return task count"}, 42 + {"unknown", "", "should return empty string for unknown field"}, 43 + } 44 + 45 + for _, tt := range tests { 46 + t.Run(tt.name, func(t *testing.T) { 47 + result := record.GetField(tt.field) 48 + if result != tt.expected { 49 + t.Errorf("GetField(%q) = %v, want %v", tt.field, result, tt.expected) 50 + } 51 + }) 52 + } 53 + }) 54 + 55 + t.Run("ModelInterface", func(t *testing.T) { 56 + if record.GetID() != 5 { 57 + t.Errorf("GetID() = %d, want 5", record.GetID()) 58 + } 59 + 60 + if record.GetTableName() != "projects" { 61 + t.Errorf("GetTableName() = %q, want 'projects'", record.GetTableName()) 62 + } 63 + 64 + if !record.GetCreatedAt().IsZero() { 65 + t.Error("GetCreatedAt() should return zero time") 66 + } 67 + 68 + if !record.GetUpdatedAt().IsZero() { 69 + t.Error("GetUpdatedAt() should return zero time") 70 + } 71 + }) 72 + }) 73 + 74 + t.Run("ProjectDataSource", func(t *testing.T) { 75 + t.Run("Load", func(t *testing.T) { 76 + projects := []repo.ProjectSummary{ 77 + {Name: "Project 1", TaskCount: 5}, 78 + {Name: "Project 2", TaskCount: 3}, 79 + } 80 + repo := &mockProjectRepository{projects: projects} 81 + source := &ProjectDataSource{repo: repo} 82 + 83 + records, err := source.Load(context.Background(), DataOptions{}) 84 + if err != nil { 85 + t.Fatalf("Load() failed: %v", err) 86 + } 87 + 88 + if len(records) != 2 { 89 + t.Errorf("Load() returned %d records, want 2", len(records)) 90 + } 91 + 92 + if records[0].GetField("name") != "Project 1" { 93 + t.Errorf("First record name = %v, want 'Project 1'", records[0].GetField("name")) 94 + } 95 + 96 + if records[0].GetField("task_count") != 5 { 97 + t.Errorf("First record task_count = %v, want 5", records[0].GetField("task_count")) 98 + } 99 + }) 100 + 101 + t.Run("Load_Error", func(t *testing.T) { 102 + testErr := fmt.Errorf("test error") 103 + repo := &mockProjectRepository{err: testErr} 104 + source := &ProjectDataSource{repo: repo} 105 + 106 + _, err := source.Load(context.Background(), DataOptions{}) 107 + if err != testErr { 108 + t.Errorf("Load() error = %v, want %v", err, testErr) 109 + } 110 + }) 111 + 112 + t.Run("Count", func(t *testing.T) { 113 + projects := []repo.ProjectSummary{ 114 + {Name: "Project 1", TaskCount: 5}, 115 + {Name: "Project 2", TaskCount: 3}, 116 + {Name: "Project 3", TaskCount: 1}, 117 + } 118 + repo := &mockProjectRepository{projects: projects} 119 + source := &ProjectDataSource{repo: repo} 120 + 121 + count, err := source.Count(context.Background(), DataOptions{}) 122 + if err != nil { 123 + t.Fatalf("Count() failed: %v", err) 124 + } 125 + 126 + if count != 3 { 127 + t.Errorf("Count() = %d, want 3", count) 128 + } 129 + }) 130 + 131 + t.Run("Count_Error", func(t *testing.T) { 132 + testErr := fmt.Errorf("test error") 133 + repo := &mockProjectRepository{err: testErr} 134 + source := &ProjectDataSource{repo: repo} 135 + 136 + _, err := source.Count(context.Background(), DataOptions{}) 137 + if err != testErr { 138 + t.Errorf("Count() error = %v, want %v", err, testErr) 139 + } 140 + }) 141 + }) 142 + 143 + t.Run("NewProjectDataTable", func(t *testing.T) { 144 + repo := &mockProjectRepository{ 145 + projects: []repo.ProjectSummary{ 146 + {Name: "Test Project", TaskCount: 2}, 147 + }, 148 + } 149 + 150 + opts := DataTableOptions{ 151 + Output: &bytes.Buffer{}, 152 + Input: strings.NewReader("q\n"), 153 + Static: true, 154 + } 155 + 156 + table := NewProjectDataTable(repo, opts) 157 + if table == nil { 158 + t.Fatal("NewProjectDataTable() returned nil") 159 + } 160 + 161 + emptyOpts := DataTableOptions{ 162 + Output: &bytes.Buffer{}, 163 + Input: strings.NewReader("q\n"), 164 + Static: true, 165 + } 166 + 167 + table2 := NewProjectDataTable(repo, emptyOpts) 168 + if table2 == nil { 169 + t.Fatal("NewProjectDataTable() with empty opts returned nil") 170 + } 171 + 172 + err := table.Browse(context.Background()) 173 + if err != nil { 174 + t.Errorf("Browse() failed: %v", err) 175 + } 176 + }) 177 + 178 + t.Run("NewProjectListFromTable", func(t *testing.T) { 179 + repo := &mockProjectRepository{ 180 + projects: []repo.ProjectSummary{ 181 + {Name: "Test Project", TaskCount: 1}, 182 + }, 183 + } 184 + 185 + output := &bytes.Buffer{} 186 + input := strings.NewReader("q\n") 187 + 188 + table := NewProjectListFromTable(repo, output, input, true) 189 + if table == nil { 190 + t.Fatal("NewProjectListFromTable() returned nil") 191 + } 192 + 193 + err := table.Browse(context.Background()) 194 + if err != nil { 195 + t.Errorf("Browse() failed: %v", err) 196 + } 197 + 198 + outputStr := output.String() 199 + if !strings.Contains(outputStr, "Projects") { 200 + t.Error("Output should contain 'Projects' title") 201 + } 202 + if !strings.Contains(outputStr, "Test Project") { 203 + t.Error("Output should contain project name") 204 + } 205 + if !strings.Contains(outputStr, "1 task") { 206 + t.Error("Output should contain formatted task count") 207 + } 208 + }) 209 + }
-375
internal/ui/project_list_test.go
··· 1 - package ui 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "fmt" 7 - "strings" 8 - "testing" 9 - 10 - tea "github.com/charmbracelet/bubbletea" 11 - "github.com/stormlightlabs/noteleaf/internal/repo" 12 - ) 13 - 14 - // Mock project repository for testing 15 - type mockProjectRepository struct { 16 - projects []repo.ProjectSummary 17 - err error 18 - } 19 - 20 - func (m *mockProjectRepository) GetProjects(ctx context.Context) ([]repo.ProjectSummary, error) { 21 - if m.err != nil { 22 - return nil, m.err 23 - } 24 - return m.projects, nil 25 - } 26 - 27 - func TestProjectList(t *testing.T) { 28 - t.Run("NewProjectList", func(t *testing.T) { 29 - t.Run("creates project list successfully", func(t *testing.T) { 30 - mockRepo := &mockProjectRepository{} 31 - opts := ProjectListOptions{} 32 - 33 - projectList := NewProjectList(mockRepo, opts) 34 - 35 - if projectList == nil { 36 - t.Fatal("ProjectList should not be nil") 37 - } 38 - if projectList.repo != mockRepo { 39 - t.Error("ProjectList repo should be set correctly") 40 - } 41 - }) 42 - 43 - t.Run("sets default options", func(t *testing.T) { 44 - mockRepo := &mockProjectRepository{} 45 - opts := ProjectListOptions{} 46 - 47 - projectList := NewProjectList(mockRepo, opts) 48 - 49 - if projectList.opts.Output == nil { 50 - t.Error("Default output should be set") 51 - } 52 - if projectList.opts.Input == nil { 53 - t.Error("Default input should be set") 54 - } 55 - }) 56 - 57 - t.Run("preserves custom options", func(t *testing.T) { 58 - mockRepo := &mockProjectRepository{} 59 - output := &bytes.Buffer{} 60 - input := strings.NewReader("") 61 - opts := ProjectListOptions{ 62 - Output: output, 63 - Input: input, 64 - Static: true, 65 - } 66 - 67 - projectList := NewProjectList(mockRepo, opts) 68 - 69 - if projectList.opts.Output != output { 70 - t.Error("Custom output should be preserved") 71 - } 72 - if projectList.opts.Input != input { 73 - t.Error("Custom input should be preserved") 74 - } 75 - if !projectList.opts.Static { 76 - t.Error("Static option should be preserved") 77 - } 78 - }) 79 - }) 80 - 81 - t.Run("StaticList", func(t *testing.T) { 82 - t.Run("displays projects correctly", func(t *testing.T) { 83 - projects := []repo.ProjectSummary{ 84 - {Name: "web-app", TaskCount: 5}, 85 - {Name: "mobile-app", TaskCount: 3}, 86 - {Name: "documentation", TaskCount: 1}, 87 - } 88 - mockRepo := &mockProjectRepository{projects: projects} 89 - output := &bytes.Buffer{} 90 - opts := ProjectListOptions{Output: output, Static: true} 91 - 92 - projectList := NewProjectList(mockRepo, opts) 93 - err := projectList.Browse(context.Background()) 94 - 95 - if err != nil { 96 - t.Errorf("Browse should not return error: %v", err) 97 - } 98 - 99 - result := output.String() 100 - if !strings.Contains(result, "Projects") { 101 - t.Error("Output should contain title") 102 - } 103 - if !strings.Contains(result, "web-app") { 104 - t.Error("Output should contain web-app project") 105 - } 106 - if !strings.Contains(result, "mobile-app") { 107 - t.Error("Output should contain mobile-app project") 108 - } 109 - if !strings.Contains(result, "5 tasks") { 110 - t.Error("Output should show correct task count for web-app") 111 - } 112 - if !strings.Contains(result, "1 task") { 113 - t.Error("Output should show singular task for documentation") 114 - } 115 - }) 116 - 117 - t.Run("handles empty project list", func(t *testing.T) { 118 - mockRepo := &mockProjectRepository{projects: []repo.ProjectSummary{}} 119 - output := &bytes.Buffer{} 120 - opts := ProjectListOptions{Output: output, Static: true} 121 - 122 - projectList := NewProjectList(mockRepo, opts) 123 - err := projectList.Browse(context.Background()) 124 - 125 - if err != nil { 126 - t.Errorf("Browse should not return error: %v", err) 127 - } 128 - 129 - result := output.String() 130 - if !strings.Contains(result, "No projects found") { 131 - t.Error("Output should indicate no projects found") 132 - } 133 - }) 134 - 135 - t.Run("handles repository errors", func(t *testing.T) { 136 - mockRepo := &mockProjectRepository{err: fmt.Errorf("database error")} 137 - output := &bytes.Buffer{} 138 - opts := ProjectListOptions{Output: output, Static: true} 139 - 140 - projectList := NewProjectList(mockRepo, opts) 141 - err := projectList.Browse(context.Background()) 142 - 143 - if err == nil { 144 - t.Error("Browse should return error when repository fails") 145 - } 146 - 147 - result := output.String() 148 - if !strings.Contains(result, "Error:") { 149 - t.Error("Output should contain error message") 150 - } 151 - }) 152 - 153 - t.Run("truncates long project names", func(t *testing.T) { 154 - projects := []repo.ProjectSummary{ 155 - {Name: "this-is-a-very-long-project-name-that-should-be-truncated", TaskCount: 2}, 156 - } 157 - mockRepo := &mockProjectRepository{projects: projects} 158 - output := &bytes.Buffer{} 159 - opts := ProjectListOptions{Output: output, Static: true} 160 - 161 - projectList := NewProjectList(mockRepo, opts) 162 - err := projectList.Browse(context.Background()) 163 - 164 - if err != nil { 165 - t.Errorf("Browse should not return error: %v", err) 166 - } 167 - 168 - result := output.String() 169 - if !strings.Contains(result, "...") { 170 - t.Error("Output should truncate long project names") 171 - } 172 - }) 173 - }) 174 - 175 - t.Run("ProjectListModel", func(t *testing.T) { 176 - t.Run("initializes correctly", func(t *testing.T) { 177 - model := projectListModel{ 178 - selected: 0, 179 - showingHelp: false, 180 - } 181 - 182 - if model.selected != 0 { 183 - t.Error("Initial selection should be 0") 184 - } 185 - if model.showingHelp { 186 - t.Error("Should not be showing help initially") 187 - } 188 - }) 189 - 190 - t.Run("handles key navigation", func(t *testing.T) { 191 - projects := []repo.ProjectSummary{ 192 - {Name: "project1", TaskCount: 1}, 193 - {Name: "project2", TaskCount: 2}, 194 - {Name: "project3", TaskCount: 3}, 195 - } 196 - 197 - model := projectListModel{ 198 - projects: projects, 199 - selected: 1, 200 - keys: projectKeys, 201 - } 202 - 203 - // Test down key 204 - downMsg := tea.KeyMsg{Type: tea.KeyDown} 205 - updatedModel, _ := model.Update(downMsg) 206 - if updatedModel.(projectListModel).selected != 2 { 207 - t.Error("Down key should move selection down") 208 - } 209 - 210 - // Test up key 211 - upMsg := tea.KeyMsg{Type: tea.KeyUp} 212 - updatedModel, _ = updatedModel.Update(upMsg) 213 - if updatedModel.(projectListModel).selected != 1 { 214 - t.Error("Up key should move selection up") 215 - } 216 - 217 - // Test boundary conditions 218 - model.selected = 0 219 - updatedModel, _ = model.Update(upMsg) 220 - if updatedModel.(projectListModel).selected != 0 { 221 - t.Error("Up key should not move selection below 0") 222 - } 223 - 224 - model.selected = len(projects) - 1 225 - updatedModel, _ = model.Update(downMsg) 226 - if updatedModel.(projectListModel).selected != len(projects)-1 { 227 - t.Error("Down key should not move selection beyond list length") 228 - } 229 - }) 230 - 231 - t.Run("handles number key selection", func(t *testing.T) { 232 - projects := []repo.ProjectSummary{ 233 - {Name: "project1", TaskCount: 1}, 234 - {Name: "project2", TaskCount: 2}, 235 - {Name: "project3", TaskCount: 3}, 236 - } 237 - 238 - model := projectListModel{ 239 - projects: projects, 240 - selected: 0, 241 - keys: projectKeys, 242 - } 243 - 244 - // Test number key 3 (index 2) 245 - keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}} 246 - updatedModel, _ := model.Update(keyMsg) 247 - if updatedModel.(projectListModel).selected != 2 { 248 - t.Error("Number key 3 should select index 2") 249 - } 250 - }) 251 - 252 - t.Run("handles help toggle", func(t *testing.T) { 253 - model := projectListModel{ 254 - keys: projectKeys, 255 - } 256 - 257 - // Toggle help on 258 - helpMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}} 259 - updatedModel, _ := model.Update(helpMsg) 260 - if !updatedModel.(projectListModel).showingHelp { 261 - t.Error("Help key should show help") 262 - } 263 - 264 - // Toggle help off 265 - updatedModel, _ = updatedModel.Update(helpMsg) 266 - if updatedModel.(projectListModel).showingHelp { 267 - t.Error("Help key should hide help when already showing") 268 - } 269 - }) 270 - 271 - t.Run("handles projects loaded message", func(t *testing.T) { 272 - projects := []repo.ProjectSummary{ 273 - {Name: "new-project", TaskCount: 5}, 274 - } 275 - 276 - model := projectListModel{ 277 - selected: 5, // Invalid selection 278 - } 279 - 280 - msg := projectsLoadedMsg(projects) 281 - updatedModel, _ := model.Update(msg) 282 - resultModel := updatedModel.(projectListModel) 283 - 284 - if len(resultModel.projects) != 1 { 285 - t.Error("Projects should be loaded correctly") 286 - } 287 - if resultModel.selected != 0 { 288 - t.Error("Selection should be reset to valid range") 289 - } 290 - }) 291 - 292 - t.Run("handles error message", func(t *testing.T) { 293 - model := projectListModel{} 294 - 295 - errorMsg := errorProjectMsg(fmt.Errorf("test error")) 296 - updatedModel, _ := model.Update(errorMsg) 297 - resultModel := updatedModel.(projectListModel) 298 - 299 - if resultModel.err == nil { 300 - t.Error("Error should be set") 301 - } 302 - if resultModel.err.Error() != "test error" { 303 - t.Errorf("Expected 'test error', got '%s'", resultModel.err.Error()) 304 - } 305 - }) 306 - 307 - t.Run("view renders correctly", func(t *testing.T) { 308 - projects := []repo.ProjectSummary{ 309 - {Name: "web-app", TaskCount: 5}, 310 - {Name: "mobile-app", TaskCount: 1}, 311 - } 312 - 313 - model := projectListModel{ 314 - projects: projects, 315 - selected: 0, 316 - keys: projectKeys, 317 - } 318 - 319 - view := model.View() 320 - if !strings.Contains(view, "Projects") { 321 - t.Error("View should contain title") 322 - } 323 - if !strings.Contains(view, "web-app") { 324 - t.Error("View should contain project names") 325 - } 326 - if !strings.Contains(view, "5 tasks") { 327 - t.Error("View should show task counts") 328 - } 329 - if !strings.Contains(view, "1 task") { 330 - t.Error("View should show singular task count") 331 - } 332 - }) 333 - 334 - t.Run("view handles empty state", func(t *testing.T) { 335 - model := projectListModel{ 336 - projects: []repo.ProjectSummary{}, 337 - } 338 - 339 - view := model.View() 340 - if !strings.Contains(view, "No projects found") { 341 - t.Error("View should show empty state message") 342 - } 343 - }) 344 - 345 - t.Run("view handles error state", func(t *testing.T) { 346 - model := projectListModel{ 347 - err: fmt.Errorf("test error"), 348 - } 349 - 350 - view := model.View() 351 - if !strings.Contains(view, "Error:") { 352 - t.Error("View should show error message") 353 - } 354 - }) 355 - }) 356 - 357 - t.Run("PluralizeCount", func(t *testing.T) { 358 - t.Run("returns empty string for singular", func(t *testing.T) { 359 - result := pluralizeCount(1) 360 - if result != "" { 361 - t.Errorf("Expected empty string for 1, got '%s'", result) 362 - } 363 - }) 364 - 365 - t.Run("returns 's' for plural", func(t *testing.T) { 366 - testCases := []int{0, 2, 10, 100} 367 - for _, count := range testCases { 368 - result := pluralizeCount(count) 369 - if result != "s" { 370 - t.Errorf("Expected 's' for %d, got '%s'", count, result) 371 - } 372 - } 373 - }) 374 - }) 375 - }
···
-287
internal/ui/tag_list.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 - tea "github.com/charmbracelet/bubbletea" 13 - "github.com/charmbracelet/lipgloss" 14 - "github.com/stormlightlabs/noteleaf/internal/repo" 15 - ) 16 - 17 - // Tag list key bindings 18 - type tagKeyMap struct { 19 - Up key.Binding 20 - Down key.Binding 21 - Enter key.Binding 22 - Refresh key.Binding 23 - Quit key.Binding 24 - Back key.Binding 25 - Help key.Binding 26 - Numbers []key.Binding 27 - } 28 - 29 - func (k tagKeyMap) ShortHelp() []key.Binding { 30 - return []key.Binding{k.Up, k.Down, k.Enter, k.Help, k.Quit} 31 - } 32 - 33 - func (k tagKeyMap) FullHelp() [][]key.Binding { 34 - return [][]key.Binding{ 35 - {k.Up, k.Down, k.Enter, k.Refresh}, 36 - {k.Help, k.Quit, k.Back}, 37 - } 38 - } 39 - 40 - var tagKeys = tagKeyMap{ 41 - Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("โ†‘/k", "move up")), 42 - Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("โ†“/j", "move down")), 43 - Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select tag")), 44 - Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), 45 - Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), 46 - Back: key.NewBinding(key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back")), 47 - Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), 48 - Numbers: []key.Binding{ 49 - key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "jump to tag 1")), 50 - key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "jump to tag 2")), 51 - key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "jump to tag 3")), 52 - key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "jump to tag 4")), 53 - key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "jump to tag 5")), 54 - key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "jump to tag 6")), 55 - key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "jump to tag 7")), 56 - key.NewBinding(key.WithKeys("8"), key.WithHelp("8", "jump to tag 8")), 57 - key.NewBinding(key.WithKeys("9"), key.WithHelp("9", "jump to tag 9")), 58 - }, 59 - } 60 - 61 - type ( 62 - // TagRepository interface for dependency injection in tests 63 - TagRepository interface { 64 - GetTags(ctx context.Context) ([]repo.TagSummary, error) 65 - } 66 - 67 - // TagListOptions configures the tag list UI behavior 68 - TagListOptions struct { 69 - // Output destination (stdout for interactive, buffer for testing) 70 - Output io.Writer 71 - // Input source (stdin for interactive, strings reader for testing) 72 - Input io.Reader 73 - // Enable static mode (no interactive components) 74 - Static bool 75 - } 76 - 77 - // TagList handles tag browsing UI 78 - TagList struct { 79 - repo TagRepository 80 - opts TagListOptions 81 - } 82 - ) 83 - 84 - // NewTagList creates a new tag list UI component 85 - func NewTagList(repo TagRepository, opts TagListOptions) *TagList { 86 - if opts.Output == nil { 87 - opts.Output = os.Stdout 88 - } 89 - if opts.Input == nil { 90 - opts.Input = os.Stdin 91 - } 92 - return &TagList{repo: repo, opts: opts} 93 - } 94 - 95 - type ( 96 - tagsLoadedMsg []repo.TagSummary 97 - errorTagMsg error 98 - tagListModel struct { 99 - tags []repo.TagSummary 100 - selected int 101 - err error 102 - repo TagRepository 103 - opts TagListOptions 104 - keys tagKeyMap 105 - help help.Model 106 - showingHelp bool 107 - } 108 - ) 109 - 110 - func (m tagListModel) Init() tea.Cmd { 111 - return m.loadTags() 112 - } 113 - 114 - func (m tagListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 115 - switch msg := msg.(type) { 116 - case tea.KeyMsg: 117 - if m.showingHelp { 118 - switch { 119 - case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Help): 120 - m.showingHelp = false 121 - return m, nil 122 - } 123 - return m, nil 124 - } 125 - 126 - switch { 127 - case key.Matches(msg, m.keys.Quit): 128 - return m, tea.Quit 129 - case key.Matches(msg, m.keys.Up): 130 - if m.selected > 0 { 131 - m.selected-- 132 - } 133 - case key.Matches(msg, m.keys.Down): 134 - if m.selected < len(m.tags)-1 { 135 - m.selected++ 136 - } 137 - case key.Matches(msg, m.keys.Enter): 138 - if len(m.tags) > 0 && m.selected < len(m.tags) { 139 - // For now, just show the selected tag name 140 - // In a real implementation, this might navigate to tasks with that tag 141 - return m, tea.Quit 142 - } 143 - case key.Matches(msg, m.keys.Refresh): 144 - return m, m.loadTags() 145 - case key.Matches(msg, m.keys.Help): 146 - m.showingHelp = true 147 - return m, nil 148 - default: 149 - // Handle number keys for quick selection 150 - for i, numKey := range m.keys.Numbers { 151 - if key.Matches(msg, numKey) && i < len(m.tags) { 152 - m.selected = i 153 - break 154 - } 155 - } 156 - } 157 - case tagsLoadedMsg: 158 - m.tags = []repo.TagSummary(msg) 159 - if m.selected >= len(m.tags) && len(m.tags) > 0 { 160 - m.selected = len(m.tags) - 1 161 - } 162 - case errorTagMsg: 163 - m.err = error(msg) 164 - } 165 - return m, nil 166 - } 167 - 168 - func (m tagListModel) View() string { 169 - var s strings.Builder 170 - 171 - style := lipgloss.NewStyle().Foreground(lipgloss.Color(Squid.Hex())) 172 - 173 - if m.showingHelp { 174 - return m.help.View(m.keys) 175 - } 176 - 177 - s.WriteString(TitleColorStyle.Render("Tags")) 178 - s.WriteString("\n\n") 179 - 180 - if m.err != nil { 181 - s.WriteString(fmt.Sprintf("Error: %s", m.err)) 182 - return s.String() 183 - } 184 - 185 - if len(m.tags) == 0 { 186 - s.WriteString("No tags found") 187 - s.WriteString("\n\n") 188 - s.WriteString(style.Render("Press r to refresh, q to quit")) 189 - return s.String() 190 - } 191 - 192 - headerLine := fmt.Sprintf(" %-25s %-15s", "Tag Name", "Task Count") 193 - s.WriteString(HeaderColorStyle.Render(headerLine)) 194 - s.WriteString("\n") 195 - s.WriteString(HeaderColorStyle.Render(strings.Repeat("โ”€", 45))) 196 - s.WriteString("\n") 197 - 198 - for i, tag := range m.tags { 199 - prefix := " " 200 - if i == m.selected { 201 - prefix = " > " 202 - } 203 - 204 - tagName := tag.Name 205 - if len(tagName) > 23 { 206 - tagName = tagName[:20] + "..." 207 - } 208 - 209 - taskCountStr := fmt.Sprintf("%d task%s", tag.TaskCount, pluralizeCount(tag.TaskCount)) 210 - 211 - line := fmt.Sprintf("%s%-25s %-15s", prefix, tagName, taskCountStr) 212 - 213 - if i == m.selected { 214 - s.WriteString(SelectedColorStyle.Render(line)) 215 - } else { 216 - s.WriteString(style.Render(line)) 217 - } 218 - 219 - s.WriteString("\n") 220 - } 221 - 222 - s.WriteString("\n") 223 - s.WriteString(m.help.View(m.keys)) 224 - 225 - return s.String() 226 - } 227 - 228 - func (m tagListModel) loadTags() tea.Cmd { 229 - return func() tea.Msg { 230 - tags, err := m.repo.GetTags(context.Background()) 231 - if err != nil { 232 - return errorTagMsg(err) 233 - } 234 - 235 - return tagsLoadedMsg(tags) 236 - } 237 - } 238 - 239 - // Browse opens an interactive TUI for navigating tags 240 - func (tl *TagList) Browse(ctx context.Context) error { 241 - if tl.opts.Static { 242 - return tl.staticList(ctx) 243 - } 244 - 245 - model := tagListModel{ 246 - repo: tl.repo, 247 - opts: tl.opts, 248 - keys: tagKeys, 249 - help: help.New(), 250 - } 251 - 252 - program := tea.NewProgram(model, tea.WithInput(tl.opts.Input), tea.WithOutput(tl.opts.Output)) 253 - 254 - _, err := program.Run() 255 - return err 256 - } 257 - 258 - func (tl *TagList) staticList(ctx context.Context) error { 259 - tags, err := tl.repo.GetTags(ctx) 260 - if err != nil { 261 - fmt.Fprintf(tl.opts.Output, "Error: %s\n", err) 262 - return err 263 - } 264 - 265 - fmt.Fprintf(tl.opts.Output, "Tags\n\n") 266 - 267 - if len(tags) == 0 { 268 - fmt.Fprintf(tl.opts.Output, "No tags found\n") 269 - return nil 270 - } 271 - 272 - fmt.Fprintf(tl.opts.Output, "%-25s %-15s\n", "Tag Name", "Task Count") 273 - fmt.Fprintf(tl.opts.Output, "%s\n", strings.Repeat("โ”€", 45)) 274 - 275 - for _, tag := range tags { 276 - tagName := tag.Name 277 - if len(tagName) > 23 { 278 - tagName = tagName[:20] + "..." 279 - } 280 - 281 - taskCountStr := fmt.Sprintf("%d task%s", tag.TaskCount, pluralizeCount(tag.TaskCount)) 282 - 283 - fmt.Fprintf(tl.opts.Output, "%-25s %-15s\n", tagName, taskCountStr) 284 - } 285 - 286 - return nil 287 - }
···
+113
internal/ui/tag_list_adapter.go
···
··· 1 + package ui 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "time" 8 + 9 + "github.com/stormlightlabs/noteleaf/internal/repo" 10 + ) 11 + 12 + // TagRepository interface for dependency injection in tests 13 + type TagRepository interface { 14 + GetTags(ctx context.Context) ([]repo.TagSummary, error) 15 + } 16 + 17 + 18 + // TagSummaryRecord adapts repo.TagSummary to work with DataTable 19 + type TagSummaryRecord struct { 20 + summary repo.TagSummary 21 + } 22 + 23 + func (t *TagSummaryRecord) GetField(name string) any { 24 + switch name { 25 + case "name": 26 + return t.summary.Name 27 + case "task_count": 28 + return t.summary.TaskCount 29 + default: 30 + return "" 31 + } 32 + } 33 + 34 + func (t *TagSummaryRecord) GetTableName() string { 35 + return "tags" 36 + } 37 + 38 + // Use task count as pseudo-ID since tags don't have IDs 39 + func (t *TagSummaryRecord) GetID() int64 { return int64(t.summary.TaskCount) } 40 + func (t *TagSummaryRecord) SetID(id int64) {} 41 + func (t *TagSummaryRecord) GetCreatedAt() time.Time { return time.Time{} } 42 + func (t *TagSummaryRecord) SetCreatedAt(time time.Time) {} 43 + func (t *TagSummaryRecord) GetUpdatedAt() time.Time { return time.Time{} } 44 + func (t *TagSummaryRecord) SetUpdatedAt(time time.Time) {} 45 + 46 + // TagDataSource adapts TagRepository to work with DataTable 47 + type TagDataSource struct { 48 + repo TagRepository 49 + } 50 + 51 + func (t *TagDataSource) Load(ctx context.Context, opts DataOptions) ([]DataRecord, error) { 52 + tags, err := t.repo.GetTags(ctx) 53 + if err != nil { 54 + return nil, err 55 + } 56 + 57 + records := make([]DataRecord, len(tags)) 58 + for i, tag := range tags { 59 + records[i] = &TagSummaryRecord{summary: tag} 60 + } 61 + 62 + return records, nil 63 + } 64 + 65 + func (t *TagDataSource) Count(ctx context.Context, opts DataOptions) (int, error) { 66 + tags, err := t.repo.GetTags(ctx) 67 + if err != nil { 68 + return 0, err 69 + } 70 + return len(tags), nil 71 + } 72 + 73 + // NewTagDataTable creates a new DataTable for browsing tags 74 + func NewTagDataTable(repo TagRepository, opts DataTableOptions) *DataTable { 75 + if opts.Title == "" { 76 + opts.Title = "Tags" 77 + } 78 + 79 + if len(opts.Fields) == 0 { 80 + opts.Fields = []Field{ 81 + { 82 + Name: "name", 83 + Title: "Tag Name", 84 + Width: 25, 85 + }, 86 + { 87 + Name: "task_count", 88 + Title: "Task Count", 89 + Width: 15, 90 + Formatter: func(value any) string { 91 + if count, ok := value.(int); ok { 92 + return fmt.Sprintf("%d task%s", count, pluralizeCount(count)) 93 + } 94 + return fmt.Sprintf("%v", value) 95 + }, 96 + }, 97 + } 98 + } 99 + 100 + source := &TagDataSource{repo: repo} 101 + return NewDataTable(source, opts) 102 + } 103 + 104 + // NewTagListFromTable creates a TagList-compatible interface using DataTable 105 + func NewTagListFromTable(repo TagRepository, output io.Writer, input io.Reader, static bool) *DataTable { 106 + opts := DataTableOptions{ 107 + Output: output, 108 + Input: input, 109 + Static: static, 110 + Title: "Tags", 111 + } 112 + return NewTagDataTable(repo, opts) 113 + }
+198
internal/ui/tag_list_adapter_test.go
···
··· 1 + package ui 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "strings" 8 + "testing" 9 + 10 + "github.com/stormlightlabs/noteleaf/internal/repo" 11 + ) 12 + 13 + // mockTagRepository implements TagRepository for testing 14 + type mockTagRepository struct { 15 + tags []repo.TagSummary 16 + err error 17 + } 18 + 19 + func (m *mockTagRepository) GetTags(ctx context.Context) ([]repo.TagSummary, error) { 20 + if m.err != nil { 21 + return nil, m.err 22 + } 23 + return m.tags, nil 24 + } 25 + 26 + func TestTagAdapter(t *testing.T) { 27 + t.Run("TagSummaryRecord", func(t *testing.T) { 28 + summary := repo.TagSummary{ 29 + Name: "work", 30 + TaskCount: 8, 31 + } 32 + record := &TagSummaryRecord{summary: summary} 33 + 34 + t.Run("GetField", func(t *testing.T) { 35 + tests := []struct { 36 + field string 37 + expected any 38 + name string 39 + }{ 40 + {"name", "work", "should return tag name"}, 41 + {"task_count", 8, "should return task count"}, 42 + {"unknown", "", "should return empty string for unknown field"}, 43 + } 44 + 45 + for _, tt := range tests { 46 + t.Run(tt.name, func(t *testing.T) { 47 + result := record.GetField(tt.field) 48 + if result != tt.expected { 49 + t.Errorf("GetField(%q) = %v, want %v", tt.field, result, tt.expected) 50 + } 51 + }) 52 + } 53 + }) 54 + 55 + t.Run("ModelInterface", func(t *testing.T) { 56 + if record.GetID() != 8 { 57 + t.Errorf("GetID() = %d, want 8", record.GetID()) 58 + } 59 + 60 + if record.GetTableName() != "tags" { 61 + t.Errorf("GetTableName() = %q, want 'tags'", record.GetTableName()) 62 + } 63 + 64 + if !record.GetCreatedAt().IsZero() { 65 + t.Error("GetCreatedAt() should return zero time") 66 + } 67 + 68 + if !record.GetUpdatedAt().IsZero() { 69 + t.Error("GetUpdatedAt() should return zero time") 70 + } 71 + }) 72 + }) 73 + 74 + t.Run("TagDataSource", func(t *testing.T) { 75 + t.Run("Load", func(t *testing.T) { 76 + tags := []repo.TagSummary{ 77 + {Name: "work", TaskCount: 5}, 78 + {Name: "personal", TaskCount: 3}, 79 + } 80 + repo := &mockTagRepository{tags: tags} 81 + source := &TagDataSource{repo: repo} 82 + 83 + records, err := source.Load(context.Background(), DataOptions{}) 84 + if err != nil { 85 + t.Fatalf("Load() failed: %v", err) 86 + } 87 + 88 + if len(records) != 2 { 89 + t.Errorf("Load() returned %d records, want 2", len(records)) 90 + } 91 + 92 + if records[0].GetField("name") != "work" { 93 + t.Errorf("First record name = %v, want 'work'", records[0].GetField("name")) 94 + } 95 + 96 + if records[0].GetField("task_count") != 5 { 97 + t.Errorf("First record task_count = %v, want 5", records[0].GetField("task_count")) 98 + } 99 + }) 100 + 101 + t.Run("Load_Error", func(t *testing.T) { 102 + testErr := fmt.Errorf("test error") 103 + repo := &mockTagRepository{err: testErr} 104 + source := &TagDataSource{repo: repo} 105 + 106 + _, err := source.Load(context.Background(), DataOptions{}) 107 + if err != testErr { 108 + t.Errorf("Load() error = %v, want %v", err, testErr) 109 + } 110 + }) 111 + 112 + t.Run("Count", func(t *testing.T) { 113 + tags := []repo.TagSummary{ 114 + {Name: "work", TaskCount: 5}, 115 + {Name: "personal", TaskCount: 3}, 116 + {Name: "urgent", TaskCount: 1}, 117 + } 118 + repo := &mockTagRepository{tags: tags} 119 + source := &TagDataSource{repo: repo} 120 + 121 + count, err := source.Count(context.Background(), DataOptions{}) 122 + if err != nil { 123 + t.Fatalf("Count() failed: %v", err) 124 + } 125 + 126 + if count != 3 { 127 + t.Errorf("Count() = %d, want 3", count) 128 + } 129 + }) 130 + 131 + t.Run("Count_Error", func(t *testing.T) { 132 + testErr := fmt.Errorf("test error") 133 + repo := &mockTagRepository{err: testErr} 134 + source := &TagDataSource{repo: repo} 135 + 136 + _, err := source.Count(context.Background(), DataOptions{}) 137 + if err != testErr { 138 + t.Errorf("Count() error = %v, want %v", err, testErr) 139 + } 140 + }) 141 + }) 142 + 143 + t.Run("NewTagDataTable", func(t *testing.T) { 144 + repo := &mockTagRepository{ 145 + tags: []repo.TagSummary{ 146 + {Name: "work", TaskCount: 4}, 147 + }, 148 + } 149 + 150 + opts := DataTableOptions{ 151 + Output: &bytes.Buffer{}, 152 + Input: strings.NewReader("q\n"), 153 + Static: true, 154 + } 155 + 156 + table := NewTagDataTable(repo, opts) 157 + if table == nil { 158 + t.Fatal("NewTagDataTable() returned nil") 159 + } 160 + 161 + err := table.Browse(context.Background()) 162 + if err != nil { 163 + t.Errorf("Browse() failed: %v", err) 164 + } 165 + }) 166 + 167 + t.Run("NewTagListFromTable", func(t *testing.T) { 168 + repo := &mockTagRepository{ 169 + tags: []repo.TagSummary{ 170 + {Name: "urgent", TaskCount: 1}, 171 + }, 172 + } 173 + 174 + output := &bytes.Buffer{} 175 + input := strings.NewReader("q\n") 176 + 177 + table := NewTagListFromTable(repo, output, input, true) 178 + if table == nil { 179 + t.Fatal("NewTagListFromTable() returned nil") 180 + } 181 + 182 + err := table.Browse(context.Background()) 183 + if err != nil { 184 + t.Errorf("Browse() failed: %v", err) 185 + } 186 + 187 + outputStr := output.String() 188 + if !strings.Contains(outputStr, "Tags") { 189 + t.Error("Output should contain 'Tags' title") 190 + } 191 + if !strings.Contains(outputStr, "urgent") { 192 + t.Error("Output should contain tag name") 193 + } 194 + if !strings.Contains(outputStr, "1 task") { 195 + t.Error("Output should contain formatted task count") 196 + } 197 + }) 198 + }
-354
internal/ui/tag_list_test.go
··· 1 - package ui 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "fmt" 7 - "strings" 8 - "testing" 9 - 10 - tea "github.com/charmbracelet/bubbletea" 11 - "github.com/stormlightlabs/noteleaf/internal/repo" 12 - ) 13 - 14 - // Mock tag repository for testing 15 - type mockTagRepository struct { 16 - tags []repo.TagSummary 17 - err error 18 - } 19 - 20 - func (m *mockTagRepository) GetTags(ctx context.Context) ([]repo.TagSummary, error) { 21 - if m.err != nil { 22 - return nil, m.err 23 - } 24 - return m.tags, nil 25 - } 26 - 27 - func TestTagList(t *testing.T) { 28 - t.Run("NewTagList", func(t *testing.T) { 29 - t.Run("creates tag list successfully", func(t *testing.T) { 30 - mockRepo := &mockTagRepository{} 31 - opts := TagListOptions{} 32 - 33 - tagList := NewTagList(mockRepo, opts) 34 - 35 - if tagList == nil { 36 - t.Fatal("TagList should not be nil") 37 - } 38 - if tagList.repo != mockRepo { 39 - t.Error("TagList repo should be set correctly") 40 - } 41 - }) 42 - 43 - t.Run("sets default options", func(t *testing.T) { 44 - mockRepo := &mockTagRepository{} 45 - opts := TagListOptions{} 46 - 47 - tagList := NewTagList(mockRepo, opts) 48 - 49 - if tagList.opts.Output == nil { 50 - t.Error("Default output should be set") 51 - } 52 - if tagList.opts.Input == nil { 53 - t.Error("Default input should be set") 54 - } 55 - }) 56 - 57 - t.Run("preserves custom options", func(t *testing.T) { 58 - mockRepo := &mockTagRepository{} 59 - output := &bytes.Buffer{} 60 - input := strings.NewReader("") 61 - opts := TagListOptions{ 62 - Output: output, 63 - Input: input, 64 - Static: true, 65 - } 66 - 67 - tagList := NewTagList(mockRepo, opts) 68 - 69 - if tagList.opts.Output != output { 70 - t.Error("Custom output should be preserved") 71 - } 72 - if tagList.opts.Input != input { 73 - t.Error("Custom input should be preserved") 74 - } 75 - if !tagList.opts.Static { 76 - t.Error("Static option should be preserved") 77 - } 78 - }) 79 - }) 80 - 81 - t.Run("StaticList", func(t *testing.T) { 82 - t.Run("displays tags correctly", func(t *testing.T) { 83 - tags := []repo.TagSummary{ 84 - {Name: "frontend", TaskCount: 5}, 85 - {Name: "backend", TaskCount: 3}, 86 - {Name: "urgent", TaskCount: 1}, 87 - } 88 - mockRepo := &mockTagRepository{tags: tags} 89 - output := &bytes.Buffer{} 90 - opts := TagListOptions{Output: output, Static: true} 91 - 92 - tagList := NewTagList(mockRepo, opts) 93 - err := tagList.Browse(context.Background()) 94 - 95 - if err != nil { 96 - t.Errorf("Browse should not return error: %v", err) 97 - } 98 - 99 - result := output.String() 100 - if !strings.Contains(result, "Tags") { 101 - t.Error("Output should contain title") 102 - } 103 - if !strings.Contains(result, "frontend") { 104 - t.Error("Output should contain frontend tag") 105 - } 106 - if !strings.Contains(result, "backend") { 107 - t.Error("Output should contain backend tag") 108 - } 109 - if !strings.Contains(result, "5 tasks") { 110 - t.Error("Output should show correct task count for frontend") 111 - } 112 - if !strings.Contains(result, "1 task") { 113 - t.Error("Output should show singular task for urgent") 114 - } 115 - }) 116 - 117 - t.Run("handles empty tag list", func(t *testing.T) { 118 - mockRepo := &mockTagRepository{tags: []repo.TagSummary{}} 119 - output := &bytes.Buffer{} 120 - opts := TagListOptions{Output: output, Static: true} 121 - 122 - tagList := NewTagList(mockRepo, opts) 123 - err := tagList.Browse(context.Background()) 124 - 125 - if err != nil { 126 - t.Errorf("Browse should not return error: %v", err) 127 - } 128 - 129 - result := output.String() 130 - if !strings.Contains(result, "No tags found") { 131 - t.Error("Output should indicate no tags found") 132 - } 133 - }) 134 - 135 - t.Run("handles repository errors", func(t *testing.T) { 136 - mockRepo := &mockTagRepository{err: fmt.Errorf("database error")} 137 - output := &bytes.Buffer{} 138 - opts := TagListOptions{Output: output, Static: true} 139 - 140 - tagList := NewTagList(mockRepo, opts) 141 - err := tagList.Browse(context.Background()) 142 - 143 - if err == nil { 144 - t.Error("Browse should return error when repository fails") 145 - } 146 - 147 - result := output.String() 148 - if !strings.Contains(result, "Error:") { 149 - t.Error("Output should contain error message") 150 - } 151 - }) 152 - 153 - t.Run("truncates long tag names", func(t *testing.T) { 154 - tags := []repo.TagSummary{ 155 - {Name: "this-is-a-very-long-tag-name-that-should-be-truncated", TaskCount: 2}, 156 - } 157 - mockRepo := &mockTagRepository{tags: tags} 158 - output := &bytes.Buffer{} 159 - opts := TagListOptions{Output: output, Static: true} 160 - 161 - tagList := NewTagList(mockRepo, opts) 162 - err := tagList.Browse(context.Background()) 163 - 164 - if err != nil { 165 - t.Errorf("Browse should not return error: %v", err) 166 - } 167 - 168 - result := output.String() 169 - if !strings.Contains(result, "...") { 170 - t.Error("Output should truncate long tag names") 171 - } 172 - }) 173 - }) 174 - 175 - t.Run("TagListModel", func(t *testing.T) { 176 - t.Run("initializes correctly", func(t *testing.T) { 177 - model := tagListModel{ 178 - selected: 0, 179 - showingHelp: false, 180 - } 181 - 182 - if model.selected != 0 { 183 - t.Error("Initial selection should be 0") 184 - } 185 - if model.showingHelp { 186 - t.Error("Should not be showing help initially") 187 - } 188 - }) 189 - 190 - t.Run("handles key navigation", func(t *testing.T) { 191 - tags := []repo.TagSummary{ 192 - {Name: "tag1", TaskCount: 1}, 193 - {Name: "tag2", TaskCount: 2}, 194 - {Name: "tag3", TaskCount: 3}, 195 - } 196 - 197 - model := tagListModel{ 198 - tags: tags, 199 - selected: 1, 200 - keys: tagKeys, 201 - } 202 - 203 - // Test down key 204 - downMsg := tea.KeyMsg{Type: tea.KeyDown} 205 - updatedModel, _ := model.Update(downMsg) 206 - if updatedModel.(tagListModel).selected != 2 { 207 - t.Error("Down key should move selection down") 208 - } 209 - 210 - // Test up key 211 - upMsg := tea.KeyMsg{Type: tea.KeyUp} 212 - updatedModel, _ = updatedModel.Update(upMsg) 213 - if updatedModel.(tagListModel).selected != 1 { 214 - t.Error("Up key should move selection up") 215 - } 216 - 217 - // Test boundary conditions 218 - model.selected = 0 219 - updatedModel, _ = model.Update(upMsg) 220 - if updatedModel.(tagListModel).selected != 0 { 221 - t.Error("Up key should not move selection below 0") 222 - } 223 - 224 - model.selected = len(tags) - 1 225 - updatedModel, _ = model.Update(downMsg) 226 - if updatedModel.(tagListModel).selected != len(tags)-1 { 227 - t.Error("Down key should not move selection beyond list length") 228 - } 229 - }) 230 - 231 - t.Run("handles number key selection", func(t *testing.T) { 232 - tags := []repo.TagSummary{ 233 - {Name: "tag1", TaskCount: 1}, 234 - {Name: "tag2", TaskCount: 2}, 235 - {Name: "tag3", TaskCount: 3}, 236 - } 237 - 238 - model := tagListModel{ 239 - tags: tags, 240 - selected: 0, 241 - keys: tagKeys, 242 - } 243 - 244 - // Test number key 3 (index 2) 245 - keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}} 246 - updatedModel, _ := model.Update(keyMsg) 247 - if updatedModel.(tagListModel).selected != 2 { 248 - t.Error("Number key 3 should select index 2") 249 - } 250 - }) 251 - 252 - t.Run("handles help toggle", func(t *testing.T) { 253 - model := tagListModel{ 254 - keys: tagKeys, 255 - } 256 - 257 - helpMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}} 258 - updatedModel, _ := model.Update(helpMsg) 259 - if !updatedModel.(tagListModel).showingHelp { 260 - t.Error("Help key should show help") 261 - } 262 - 263 - updatedModel, _ = updatedModel.Update(helpMsg) 264 - if updatedModel.(tagListModel).showingHelp { 265 - t.Error("Help key should hide help when already showing") 266 - } 267 - }) 268 - 269 - t.Run("handles tags loaded message", func(t *testing.T) { 270 - tags := []repo.TagSummary{ 271 - {Name: "new-tag", TaskCount: 5}, 272 - } 273 - 274 - model := tagListModel{ 275 - selected: 5, // Invalid selection 276 - } 277 - 278 - msg := tagsLoadedMsg(tags) 279 - updatedModel, _ := model.Update(msg) 280 - resultModel := updatedModel.(tagListModel) 281 - 282 - if len(resultModel.tags) != 1 { 283 - t.Error("Tags should be loaded correctly") 284 - } 285 - if resultModel.selected != 0 { 286 - t.Error("Selection should be reset to valid range") 287 - } 288 - }) 289 - 290 - t.Run("handles error message", func(t *testing.T) { 291 - model := tagListModel{} 292 - 293 - errorMsg := errorTagMsg(fmt.Errorf("test error")) 294 - updatedModel, _ := model.Update(errorMsg) 295 - resultModel := updatedModel.(tagListModel) 296 - 297 - if resultModel.err == nil { 298 - t.Error("Error should be set") 299 - } 300 - if resultModel.err.Error() != "test error" { 301 - t.Errorf("Expected 'test error', got '%s'", resultModel.err.Error()) 302 - } 303 - }) 304 - 305 - t.Run("view renders correctly", func(t *testing.T) { 306 - tags := []repo.TagSummary{ 307 - {Name: "frontend", TaskCount: 5}, 308 - {Name: "urgent", TaskCount: 1}, 309 - } 310 - 311 - model := tagListModel{ 312 - tags: tags, 313 - selected: 0, 314 - keys: tagKeys, 315 - } 316 - 317 - view := model.View() 318 - if !strings.Contains(view, "Tags") { 319 - t.Error("View should contain title") 320 - } 321 - if !strings.Contains(view, "frontend") { 322 - t.Error("View should contain tag names") 323 - } 324 - if !strings.Contains(view, "5 tasks") { 325 - t.Error("View should show task counts") 326 - } 327 - if !strings.Contains(view, "1 task") { 328 - t.Error("View should show singular task count") 329 - } 330 - }) 331 - 332 - t.Run("view handles empty state", func(t *testing.T) { 333 - model := tagListModel{ 334 - tags: []repo.TagSummary{}, 335 - } 336 - 337 - view := model.View() 338 - if !strings.Contains(view, "No tags found") { 339 - t.Error("View should show empty state message") 340 - } 341 - }) 342 - 343 - t.Run("view handles error state", func(t *testing.T) { 344 - model := tagListModel{ 345 - err: fmt.Errorf("test error"), 346 - } 347 - 348 - view := model.View() 349 - if !strings.Contains(view, "Error:") { 350 - t.Error("View should show error message") 351 - } 352 - }) 353 - }) 354 - }
···