cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
29
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 355 lines 9.3 kB view raw
1package ui 2 3import ( 4 "context" 5 "fmt" 6 "io" 7 "sync" 8 "testing" 9 "time" 10 11 tea "github.com/charmbracelet/bubbletea" 12 "github.com/stormlightlabs/noteleaf/internal/models" 13 "github.com/stormlightlabs/noteleaf/internal/shared" 14) 15 16type AssertionHelpers struct{} 17 18// TUITestSuite provides comprehensive testing infrastructure for BubbleTea models 19// with channel-based control and signal handling for interactive testing 20type TUITestSuite struct { 21 t *testing.T 22 model tea.Model 23 program *tea.Program 24 msgChan chan tea.Msg 25 doneChan chan struct{} 26 outputBuf *ControlledOutput 27 inputBuf *ControlledInput 28 mu sync.RWMutex 29 updates []tea.Model 30 views []string 31 finished bool 32 ctx context.Context 33 cancel context.CancelFunc 34} 35 36// ControlledOutput captures program output for verification 37type ControlledOutput struct { 38 buf []byte 39 mu sync.RWMutex 40 writes [][]byte 41} 42 43func (co *ControlledOutput) Write(p []byte) (n int, err error) { 44 co.mu.Lock() 45 defer co.mu.Unlock() 46 co.buf = append(co.buf, p...) 47 co.writes = append(co.writes, append([]byte(nil), p...)) 48 return len(p), nil 49} 50 51func (co *ControlledOutput) GetOutput() []byte { 52 co.mu.RLock() 53 defer co.mu.RUnlock() 54 return append([]byte(nil), co.buf...) 55} 56 57func (co *ControlledOutput) GetWrites() [][]byte { 58 co.mu.RLock() 59 defer co.mu.RUnlock() 60 writes := make([][]byte, len(co.writes)) 61 for i, w := range co.writes { 62 writes[i] = append([]byte(nil), w...) 63 } 64 return writes 65} 66 67// ControlledInput provides controlled input simulation 68type ControlledInput struct { 69 sequences []tea.Msg 70 mu sync.RWMutex 71} 72 73// Read is primarily for compatibility - actual input comes through channels 74func (ci *ControlledInput) Read(p []byte) (n int, err error) { 75 return 0, io.EOF 76} 77 78func (ci *ControlledInput) QueueMessage(msg tea.Msg) { 79 ci.mu.Lock() 80 defer ci.mu.Unlock() 81 ci.sequences = append(ci.sequences, msg) 82} 83 84// NewTUITestSuite creates a new TUI test suite with controlled I/O and channels 85func NewTUITestSuite(t *testing.T, model tea.Model, opts ...TUITestOption) *TUITestSuite { 86 ctx, cancel := context.WithCancel(context.Background()) 87 88 suite := &TUITestSuite{ 89 t: t, 90 model: model, 91 msgChan: make(chan tea.Msg, 100), 92 doneChan: make(chan struct{}), 93 outputBuf: &ControlledOutput{}, 94 inputBuf: &ControlledInput{}, 95 updates: []tea.Model{}, 96 views: []string{}, 97 ctx: ctx, 98 cancel: cancel, 99 } 100 101 for _, opt := range opts { 102 opt(suite) 103 } 104 105 suite.setupProgram() 106 107 t.Cleanup(func() { 108 suite.Close() 109 }) 110 111 return suite 112} 113 114// TUITestOption configures the test suite 115type TUITestOption func(*TUITestSuite) 116 117// WithInitialSize sets the initial terminal size by storing size for program initialization 118func WithInitialSize(width, height int) TUITestOption { 119 return func(suite *TUITestSuite) { 120 suite.msgChan <- tea.WindowSizeMsg{Width: width, Height: height} 121 } 122} 123 124// WithTimeout sets a global timeout for operations 125func WithTimeout(timeout time.Duration) TUITestOption { 126 return func(suite *TUITestSuite) { 127 ctx, cancel := context.WithTimeout(suite.ctx, timeout) 128 suite.ctx = ctx 129 suite.cancel = cancel 130 } 131} 132 133// setupProgram creates a program with controlled I/O by... 134// 135// Disabling signals for testing 136// Disabling renderer for testing 137func (suite *TUITestSuite) setupProgram() { 138 // For unit testing, we'll directly test the model instead of running a full program 139 suite.program = nil 140} 141 142// Start begins the test program in a goroutine with time to initialize 143func (suite *TUITestSuite) Start() { 144 if cmd := suite.model.Init(); cmd != nil { 145 suite.executeCmd(cmd) 146 } 147 148 suite.mu.Lock() 149 suite.updates = append(suite.updates, suite.model) 150 suite.views = append(suite.views, suite.model.View()) 151 suite.mu.Unlock() 152} 153 154// SendKey sends a key press message to the model 155func (suite *TUITestSuite) SendKey(keyType tea.KeyType, runes ...rune) error { 156 msg := tea.KeyMsg{Type: keyType} 157 if len(runes) > 0 { 158 msg.Type = tea.KeyRunes 159 msg.Runes = runes 160 } 161 return suite.SendMessage(msg) 162} 163 164// SendKeyString sends a string as key runes 165func (suite *TUITestSuite) SendKeyString(s string) error { 166 return suite.SendKey(tea.KeyRunes, []rune(s)...) 167} 168 169// SendMessage sends an arbitrary message to the model 170func (suite *TUITestSuite) SendMessage(msg tea.Msg) error { 171 newModel, cmd := suite.model.Update(msg) 172 suite.model = newModel 173 174 if cmd != nil { 175 suite.executeCmd(cmd) 176 } 177 178 suite.mu.Lock() 179 suite.updates = append(suite.updates, suite.model) 180 suite.views = append(suite.views, suite.model.View()) 181 suite.mu.Unlock() 182 183 return nil 184} 185 186// WaitFor waits for a condition to be met within the timeout 187func (suite *TUITestSuite) WaitFor(condition func(tea.Model) bool, timeout time.Duration) error { 188 ctx, cancel := context.WithTimeout(suite.ctx, timeout) 189 defer cancel() 190 191 ticker := time.NewTicker(10 * time.Millisecond) 192 defer ticker.Stop() 193 194 for { 195 select { 196 case <-ctx.Done(): 197 return fmt.Errorf("condition not met within timeout: %w", ctx.Err()) 198 case <-ticker.C: 199 suite.mu.RLock() 200 if len(suite.updates) > 0 { 201 currentModel := suite.updates[len(suite.updates)-1] 202 if condition(currentModel) { 203 suite.mu.RUnlock() 204 return nil 205 } 206 } 207 suite.mu.RUnlock() 208 } 209 } 210} 211 212// WaitForView waits for a view to contain specific content 213func (suite *TUITestSuite) WaitForView(contains string, timeout time.Duration) error { 214 return suite.WaitFor(func(model tea.Model) bool { 215 view := model.View() 216 return len(view) > 0 && shared.ContainsString(view, contains) 217 }, timeout) 218} 219 220// GetCurrentModel returns the latest model state (thread-safe) 221func (suite *TUITestSuite) GetCurrentModel() tea.Model { 222 suite.mu.RLock() 223 defer suite.mu.RUnlock() 224 225 if len(suite.updates) == 0 { 226 return suite.model 227 } 228 return suite.updates[len(suite.updates)-1] 229} 230 231// GetCurrentView returns the latest view output 232func (suite *TUITestSuite) GetCurrentView() string { 233 model := suite.GetCurrentModel() 234 return model.View() 235} 236 237// GetOutput returns all captured output 238func (suite *TUITestSuite) GetOutput() []byte { 239 return suite.outputBuf.GetOutput() 240} 241 242// executeCmd executes any commands returned by model updates 243// 244// For unit testing, we ignore commands or handle specific ones we care about 245// This could be extended to handle specific command types if needed 246func (suite *TUITestSuite) executeCmd(cmd tea.Cmd) { 247 if cmd == nil { 248 return 249 } 250} 251 252// Close properly shuts down the test suite 253func (suite *TUITestSuite) Close() { 254 if !suite.finished { 255 suite.finished = true 256 suite.cancel() 257 } 258} 259 260// SimulateKeySequence sends a sequence of keys with timing 261func (suite *TUITestSuite) SimulateKeySequence(keys []KeyWithTiming) error { 262 for _, key := range keys { 263 if err := suite.SendKey(key.KeyType, key.Runes...); err != nil { 264 return fmt.Errorf("failed to send key %v: %w", key.KeyType, err) 265 } 266 if key.Delay > 0 { 267 time.Sleep(key.Delay) 268 } 269 } 270 return nil 271} 272 273// KeyWithTiming represents a key press with optional delay 274type KeyWithTiming struct { 275 KeyType tea.KeyType 276 Runes []rune 277 Delay time.Duration 278} 279 280// MockTaskRepository provides a mock implementation for testing 281type MockTaskRepository struct { 282 tasks map[int64]*models.Task 283 updated []*models.Task 284 mu sync.RWMutex 285} 286 287func NewMockTaskRepository() *MockTaskRepository { 288 return &MockTaskRepository{ 289 tasks: make(map[int64]*models.Task), 290 } 291} 292 293func (m *MockTaskRepository) AddTask(task *models.Task) { 294 m.mu.Lock() 295 defer m.mu.Unlock() 296 m.tasks[task.ID] = task 297} 298 299func (m *MockTaskRepository) GetUpdatedTasks() []*models.Task { 300 m.mu.RLock() 301 defer m.mu.RUnlock() 302 result := make([]*models.Task, len(m.updated)) 303 copy(result, m.updated) 304 return result 305} 306 307func (ah *AssertionHelpers) AssertModelState(t *testing.T, suite *TUITestSuite, checker func(tea.Model) bool, msg string) { 308 t.Helper() 309 model := suite.GetCurrentModel() 310 if !checker(model) { 311 t.Errorf("Model state assertion failed: %s", msg) 312 } 313} 314 315func (ah *AssertionHelpers) AssertViewContains(t *testing.T, suite *TUITestSuite, expected string, msg string) { 316 t.Helper() 317 view := suite.GetCurrentView() 318 if !shared.ContainsString(view, expected) { 319 t.Errorf("View assertion failed: %s\nView content: %s\nExpected to contain: %s", msg, view, expected) 320 } 321} 322 323func (ah *AssertionHelpers) AssertViewNotContains(t *testing.T, suite *TUITestSuite, unexpected string, msg string) { 324 t.Helper() 325 view := suite.GetCurrentView() 326 if shared.ContainsString(view, unexpected) { 327 t.Errorf("View assertion failed: %s\nView content: %s\nShould not contain: %s", msg, view, unexpected) 328 } 329} 330 331func (ah *AssertionHelpers) AssertZeroTime(t *testing.T, getter func() time.Time, label string) { 332 t.Helper() 333 if !getter().IsZero() { 334 t.Errorf("%v() should return zero time", label) 335 } 336} 337 338var Expect = AssertionHelpers{} 339 340// Test generators for switch case coverage 341type SwitchCaseTest struct { 342 Name string 343 Input any 344 Expected any 345 ShouldError bool 346 Setup func(*TUITestSuite) 347 Verify func(*testing.T, *TUITestSuite) 348} 349 350// CreateTestSuiteWithModel is a helper to create a test suite with a specific model 351// 352// This should be used in individual test files where the model type is known 353func CreateTestSuiteWithModel(t *testing.T, model tea.Model, opts ...TUITestOption) *TUITestSuite { 354 return NewTUITestSuite(t, model, opts...) 355}