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