cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
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}