cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
1package handlers
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "os"
8 "path/filepath"
9 "strconv"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14
15 tea "github.com/charmbracelet/bubbletea"
16 "github.com/stormlightlabs/noteleaf/internal/articles"
17 "github.com/stormlightlabs/noteleaf/internal/models"
18 "github.com/stormlightlabs/noteleaf/internal/repo"
19 "github.com/stormlightlabs/noteleaf/internal/services"
20 "github.com/stormlightlabs/noteleaf/internal/store"
21 "github.com/stormlightlabs/noteleaf/internal/ui"
22)
23
24// HandlerTestHelper wraps [NoteHandler] with test-specific functionality
25//
26// Uses [HandlerTestSuite] internally to avoid code duplication
27type HandlerTestHelper struct {
28 *NoteHandler
29 suite *HandlerTestSuite
30}
31
32// NewHandlerTestHelper creates a [NoteHandler] with isolated test database
33func NewHandlerTestHelper(t *testing.T) *HandlerTestHelper {
34 suite := NewHandlerTestSuite(t)
35
36 handler, err := NewNoteHandler()
37 if err != nil {
38 t.Fatalf("Failed to create note handler: %v", err)
39 }
40
41 testHandler := &HandlerTestHelper{
42 NoteHandler: handler,
43 suite: suite,
44 }
45
46 t.Cleanup(func() {
47 testHandler.Close()
48 })
49
50 return testHandler
51}
52
53// CreateTestNote creates a test note and returns its ID
54func (th *HandlerTestHelper) CreateTestNote(t *testing.T, title, content string, tags []string) int64 {
55 ctx := context.Background()
56 note := &models.Note{
57 Title: title,
58 Content: content,
59 Tags: tags,
60 Created: time.Now(),
61 Modified: time.Now(),
62 }
63
64 id, err := th.repos.Notes.Create(ctx, note)
65 if err != nil {
66 t.Fatalf("Failed to create test note: %v", err)
67 }
68 return id
69}
70
71// CreateTestFile creates a temporary markdown file with content
72func (th *HandlerTestHelper) CreateTestFile(t *testing.T, filename, content string) string {
73 filePath := filepath.Join(th.suite.TempDir(), filename)
74 err := os.WriteFile(filePath, []byte(content), 0644)
75 if err != nil {
76 t.Fatalf("Failed to create test file: %v", err)
77 }
78 return filePath
79}
80
81// MockEditor provides a mock editor function for testing
82type MockEditor struct {
83 shouldFail bool
84 failureMsg string
85 contentToWrite string
86 deleteFile bool
87 makeReadOnly bool
88}
89
90// NewMockEditor creates a mock editor with default success behavior
91func NewMockEditor() *MockEditor {
92 return &MockEditor{
93 contentToWrite: `# Test Note
94
95Test content here.
96
97<!-- Tags: test -->`,
98 }
99}
100
101// WithFailure configures the mock editor to fail
102func (me *MockEditor) WithFailure(msg string) *MockEditor {
103 me.shouldFail = true
104 me.failureMsg = msg
105 return me
106}
107
108// WithContent configures the content the mock editor will write
109func (me *MockEditor) WithContent(content string) *MockEditor {
110 me.contentToWrite = content
111 return me
112}
113
114// WithFileDeleted configures the mock editor to delete the temp file
115func (me *MockEditor) WithFileDeleted() *MockEditor {
116 me.deleteFile = true
117 return me
118}
119
120// WithReadOnly configures the mock editor to make the file read-only
121func (me *MockEditor) WithReadOnly() *MockEditor {
122 me.makeReadOnly = true
123 return me
124}
125
126// GetEditorFunc returns the editor function for use with [NoteHandler]
127func (me *MockEditor) GetEditorFunc() editorFunc {
128 return func(editor, filePath string) error {
129 if me.shouldFail {
130 return fmt.Errorf("%s", me.failureMsg)
131 }
132
133 if me.deleteFile {
134 return os.Remove(filePath)
135 }
136
137 if me.makeReadOnly {
138 os.Chmod(filePath, 0444)
139 return nil
140 }
141
142 return os.WriteFile(filePath, []byte(me.contentToWrite), 0644)
143 }
144}
145
146// DatabaseTestHelper provides database testing utilities
147type DatabaseTestHelper struct {
148 originalDB *store.Database
149 handler *HandlerTestHelper
150}
151
152// NewDatabaseTestHelper creates a helper for database error testing
153func NewDatabaseTestHelper(handler *HandlerTestHelper) *DatabaseTestHelper {
154 return &DatabaseTestHelper{
155 originalDB: handler.db,
156 handler: handler,
157 }
158}
159
160// CloseDatabase closes the database connection
161func (dth *DatabaseTestHelper) CloseDatabase() {
162 dth.handler.db.Close()
163}
164
165// RestoreDatabase restores the original database connection
166func (dth *DatabaseTestHelper) RestoreDatabase(t *testing.T) {
167 var err error
168 dth.handler.db, err = store.NewDatabase()
169 if err != nil {
170 t.Fatalf("Failed to restore database: %v", err)
171 }
172}
173
174// DropNotesTable drops the notes table to simulate database errors
175func (dth *DatabaseTestHelper) DropNotesTable() {
176 dth.handler.db.Exec("DROP TABLE notes")
177}
178
179// CreateCorruptedDatabase creates a new database with corrupted schema
180func (dth *DatabaseTestHelper) CreateCorruptedDatabase(t *testing.T) {
181 dth.CloseDatabase()
182
183 db, err := store.NewDatabase()
184 if err != nil {
185 t.Fatalf("Failed to create corrupted database: %v", err)
186 }
187
188 db.Exec("DROP TABLE notes")
189 dth.handler.db = db
190}
191
192// EnvironmentTestHelper provides environment manipulation utilities for testing
193type EnvironmentTestHelper struct {
194 originalVars map[string]string
195}
196
197// NewEnvironmentTestHelper creates a new environment test helper
198func NewEnvironmentTestHelper() *EnvironmentTestHelper {
199 return &EnvironmentTestHelper{
200 originalVars: make(map[string]string),
201 }
202}
203
204// SetEnv sets an environment variable and remembers the original value
205func (eth *EnvironmentTestHelper) SetEnv(key, value string) {
206 if _, exists := eth.originalVars[key]; !exists {
207 eth.originalVars[key] = os.Getenv(key)
208 }
209 os.Setenv(key, value)
210}
211
212// UnsetEnv unsets an environment variable and remembers the original value
213func (eth *EnvironmentTestHelper) UnsetEnv(key string) {
214 if _, exists := eth.originalVars[key]; !exists {
215 eth.originalVars[key] = os.Getenv(key)
216 }
217 os.Unsetenv(key)
218}
219
220// RestoreEnv restores all modified environment variables
221func (eth *EnvironmentTestHelper) RestoreEnv() {
222 for key, value := range eth.originalVars {
223 if value == "" {
224 os.Unsetenv(key)
225 } else {
226 os.Setenv(key, value)
227 }
228 }
229}
230
231// CreateTestDir creates a temporary test directory and sets up environment
232func (eth *EnvironmentTestHelper) CreateTestDir(t *testing.T) (string, error) {
233 tempDir, err := os.MkdirTemp("", "noteleaf-test-*")
234 if err != nil {
235 return "", err
236 }
237
238 eth.SetEnv("XDG_CONFIG_HOME", tempDir)
239
240 ctx := context.Background()
241 err = Setup(ctx, []string{})
242 if err != nil {
243 os.RemoveAll(tempDir)
244 return "", err
245 }
246
247 t.Cleanup(func() {
248 eth.RestoreEnv()
249 os.RemoveAll(tempDir)
250 })
251
252 return tempDir, nil
253}
254
255// ArticleTestHelper wraps [ArticleHandler] with test-specific functionality
256type ArticleTestHelper struct {
257 *ArticleHandler
258 suite *HandlerTestSuite
259}
260
261// NewArticleTestHelper creates an [ArticleHandler] with isolated test database
262func NewArticleTestHelper(t *testing.T) *ArticleTestHelper {
263 suite := NewHandlerTestSuite(t)
264
265 handler, err := NewArticleHandler()
266 if err != nil {
267 t.Fatalf("Failed to create article handler: %v", err)
268 }
269
270 testHelper := &ArticleTestHelper{
271 ArticleHandler: handler,
272 suite: suite,
273 }
274
275 t.Cleanup(func() {
276 testHelper.Close()
277 })
278
279 return testHelper
280}
281
282// CreateTestArticle creates a test article and returns its ID
283func (ath *ArticleTestHelper) CreateTestArticle(t *testing.T, url, title, author, date string) int64 {
284 ctx := context.Background()
285
286 mdPath := filepath.Join(ath.suite.TempDir(), fmt.Sprintf("%s.md", title))
287 htmlPath := filepath.Join(ath.suite.TempDir(), fmt.Sprintf("%s.html", title))
288
289 mdContent := fmt.Sprintf("# %s\n\n**Author:** %s\n**Date:** %s\n\nTest content", title, author, date)
290 err := os.WriteFile(mdPath, []byte(mdContent), 0644)
291 if err != nil {
292 t.Fatalf("Failed to create test markdown file: %v", err)
293 }
294
295 htmlContent := fmt.Sprintf("<h1>%s</h1><p>Author: %s</p><p>Date: %s</p><p>Test content</p>", title, author, date)
296 err = os.WriteFile(htmlPath, []byte(htmlContent), 0644)
297 if err != nil {
298 t.Fatalf("Failed to create test HTML file: %v", err)
299 }
300
301 article := &models.Article{
302 URL: url,
303 Title: title,
304 Author: author,
305 Date: date,
306 MarkdownPath: mdPath,
307 HTMLPath: htmlPath,
308 Created: time.Now(),
309 Modified: time.Now(),
310 }
311
312 id, err := ath.repos.Articles.Create(ctx, article)
313 if err != nil {
314 t.Fatalf("Failed to create test article: %v", err)
315 }
316 return id
317}
318
319// AddTestRule adds a parsing rule to the handler's parser for testing
320func (ath *ArticleTestHelper) AddTestRule(domain string, rule *articles.ParsingRule) {
321 if parser, ok := ath.parser.(*articles.ArticleParser); ok {
322 parser.AddRule(domain, rule)
323 } else {
324 panic("Could not cast parser to ArticleParser")
325 }
326}
327
328// MockOpenLibraryResponse creates a mocked instance of [services.OpenLibrarySearchResponse]
329func MockOpenLibraryResponse(books []MockBook) services.OpenLibrarySearchResponse {
330 docs := make([]services.OpenLibrarySearchDoc, len(books))
331 for i, book := range books {
332 docs[i] = services.OpenLibrarySearchDoc{
333 Key: book.Key,
334 Title: book.Title,
335 AuthorName: book.Authors,
336 FirstPublishYear: book.Year,
337 Edition_count: book.Editions,
338 CoverI: book.CoverID,
339 }
340 }
341 return services.OpenLibrarySearchResponse{
342 NumFound: len(books),
343 Start: 0,
344 Docs: docs,
345 }
346}
347
348// MockBook represents a book for testing
349type MockBook struct {
350 Key string
351 Title string
352 Authors []string
353 Year int
354 Editions int
355 CoverID int
356}
357
358// MockRottenTomatoesResponse creates a mock HTML response for Rotten Tomatoes
359func MockRottenTomatoesResponse(movies []MockMedia) string {
360 var html strings.Builder
361 html.WriteString(`<html><body><div class="search-page-result">`)
362
363 for _, movie := range movies {
364 html.WriteString(fmt.Sprintf(`
365 <div class="mb-movie" data-qa="result-item">
366 <div class="poster">
367 <a href="%s" title="%s">
368 <img src="poster.jpg" alt="%s">
369 </a>
370 </div>
371 <div class="info">
372 <h3><a href="%s">%s</a></h3>
373 <div class="critics-score">%s</div>
374 </div>
375 </div>
376 `, movie.Link, movie.Title, movie.Title, movie.Link, movie.Title, movie.Score))
377 }
378
379 html.WriteString(`</div></body></html>`)
380 return html.String()
381}
382
383// MockMedia represents media for testing
384type MockMedia struct {
385 Title string
386 Link string
387 Score string
388 Type string
389}
390
391// MockMediaFetcher provides a test implementation of Fetchable and Searchable interfaces
392type MockMediaFetcher struct {
393 SearchResults []services.Media
394 HTMLContent string
395 MovieData *services.Movie
396 TVSeriesData *services.TVSeries
397 ShouldError bool
398 ErrorMessage string
399}
400
401// Search implements the Searchable interface for testing
402func (m *MockMediaFetcher) Search(query string) ([]services.Media, error) {
403 if m.ShouldError {
404 return nil, fmt.Errorf("mock search error: %s", m.ErrorMessage)
405 }
406 return m.SearchResults, nil
407}
408
409// MakeRequest implements the Fetchable interface for testing
410func (m *MockMediaFetcher) MakeRequest(url string) (string, error) {
411 if m.ShouldError {
412 return "", fmt.Errorf("mock fetch error: %s", m.ErrorMessage)
413 }
414 return m.HTMLContent, nil
415}
416
417// MovieRequest implements the Fetchable interface for testing
418func (m *MockMediaFetcher) MovieRequest(url string) (*services.Movie, error) {
419 if m.ShouldError {
420 return nil, fmt.Errorf("mock movie fetch error: %s", m.ErrorMessage)
421 }
422 if m.MovieData == nil {
423 return nil, fmt.Errorf("movie not found")
424 }
425 return m.MovieData, nil
426}
427
428// TVRequest implements the Fetchable interface for testing
429func (m *MockMediaFetcher) TVRequest(url string) (*services.TVSeries, error) {
430 if m.ShouldError {
431 return nil, fmt.Errorf("mock tv series fetch error: %s", m.ErrorMessage)
432 }
433 if m.TVSeriesData == nil {
434 return nil, fmt.Errorf("tv series not found")
435 }
436 return m.TVSeriesData, nil
437}
438
439// CreateTestMovieService creates a MovieService with mock dependencies for testing
440func CreateTestMovieService(mockFetcher *MockMediaFetcher) *services.MovieService {
441 return services.NewMovieSrvWithOpts("http://localhost", mockFetcher, mockFetcher)
442}
443
444// CreateTestTVService creates a TVService with mock dependencies for testing
445func CreateTestTVService(mockFetcher *MockMediaFetcher) *services.TVService {
446 return services.NewTVServiceWithDeps("http://localhost", mockFetcher, mockFetcher)
447}
448
449// CreateMockMovieSearchResults creates sample movie search results for testing
450func CreateMockMovieSearchResults() []services.Media {
451 return []services.Media{
452 {Title: "Test Movie 1", Link: "/m/test_movie_1", Type: "movie", CriticScore: "85%"},
453 {Title: "Test Movie 2", Link: "/m/test_movie_2", Type: "movie", CriticScore: "72%"},
454 }
455}
456
457// CreateMockTVSearchResults creates sample TV search results for testing
458func CreateMockTVSearchResults() []services.Media {
459 return []services.Media{
460 {Title: "Test TV Show 1", Link: "/tv/test_show_1", Type: "tv", CriticScore: "90%"},
461 {Title: "Test TV Show 2", Link: "/tv/test_show_2", Type: "tv", CriticScore: "80%"},
462 }
463}
464
465// InputSimulator provides controlled input simulation for testing [fmt.Scanf] interactions
466// It implements [io.Reader] to provide predictable input sequences for interactive components
467type InputSimulator struct {
468 inputs []string
469 position int
470 mu sync.RWMutex
471}
472
473// NewInputSimulator creates a new input simulator with the given input sequence
474func NewInputSimulator(inputs ...string) *InputSimulator {
475 return &InputSimulator{inputs: inputs}
476}
477
478// Read implements [io.Reader] interface for [fmt.Scanf] compatibility
479func (is *InputSimulator) Read(p []byte) (n int, err error) {
480 is.mu.Lock()
481 defer is.mu.Unlock()
482
483 if is.position >= len(is.inputs) {
484 return 0, io.EOF
485 }
486
487 input := is.inputs[is.position] + "\n"
488 is.position++
489
490 n = copy(p, []byte(input))
491 return n, nil
492}
493
494// Reset resets the simulator to the beginning of the input sequence
495func (is *InputSimulator) Reset() {
496 is.mu.Lock()
497 defer is.mu.Unlock()
498 is.position = 0
499}
500
501// AddInputs appends new inputs to the sequence
502func (is *InputSimulator) AddInputs(inputs ...string) {
503 is.mu.Lock()
504 defer is.mu.Unlock()
505 is.inputs = append(is.inputs, inputs...)
506}
507
508// HasMoreInputs returns true if there are more inputs available
509func (is *InputSimulator) HasMoreInputs() bool {
510 is.mu.RLock()
511 defer is.mu.RUnlock()
512 return is.position < len(is.inputs)
513}
514
515// MenuSelection creates input simulator for menu selection scenarios
516func MenuSelection(choice int) *InputSimulator {
517 return NewInputSimulator(strconv.Itoa(choice))
518}
519
520// MenuCancel creates input simulator for cancelling menu selection
521func MenuCancel() *InputSimulator {
522 return NewInputSimulator("0")
523}
524
525// MenuSequence creates input simulator for multiple menu interactions
526func MenuSequence(choices ...int) *InputSimulator {
527 inputs := make([]string, len(choices))
528 for i, choice := range choices {
529 inputs[i] = strconv.Itoa(choice)
530 }
531 return NewInputSimulator(inputs...)
532}
533
534// InteractiveTestHelper provides utilities for testing interactive handler components
535type InteractiveTestHelper struct {
536 Stdin io.Reader
537 sim *InputSimulator
538}
539
540// NewInteractiveTestHelper creates a helper for testing interactive components
541func NewInteractiveTestHelper() *InteractiveTestHelper {
542 return &InteractiveTestHelper{}
543}
544
545// SimulateInput sets up input simulation for the test
546func (ith *InteractiveTestHelper) SimulateInput(inputs ...string) *InputSimulator {
547 ith.sim = NewInputSimulator(inputs...)
548 return ith.sim
549}
550
551// SimulateMenuChoice sets up input simulation for menu selection
552func (ith *InteractiveTestHelper) SimulateMenuChoice(choice int) *InputSimulator {
553 return ith.SimulateInput(strconv.Itoa(choice))
554}
555
556// SimulateCancel sets up input simulation for cancellation
557func (ith *InteractiveTestHelper) SimulateCancel() *InputSimulator {
558 return ith.SimulateInput("0")
559}
560
561// GetSimulator returns the current input simulator
562func (ith *InteractiveTestHelper) GetSimulator() *InputSimulator {
563 return ith.sim
564}
565
566// TUICapableHandler interface for handlers that can expose TUI models for testing
567type TUICapableHandler interface {
568 GetTUIModel(ctx context.Context, opts TUITestOptions) (tea.Model, error)
569 SetTUITestMode(enabled bool)
570}
571
572// TUITestOptions configures TUI testing behavior for handlers
573type TUITestOptions struct {
574 Width int
575 Height int
576 Static bool
577 Output io.Writer
578 Input io.Reader
579 MockData any
580}
581
582// InteractiveTUIHelper bridges handler testing with TUI testing capabilities
583type InteractiveTUIHelper struct {
584 t *testing.T
585 handler TUICapableHandler
586 suite *ui.TUITestSuite
587 opts TUITestOptions
588}
589
590// NewInteractiveTUIHelper creates a helper for testing handler TUI interactions
591func NewInteractiveTUIHelper(t *testing.T, handler TUICapableHandler) *InteractiveTUIHelper {
592 return &InteractiveTUIHelper{
593 t: t,
594 handler: handler,
595 opts: TUITestOptions{
596 Width: 80,
597 Height: 24,
598 },
599 }
600}
601
602// WithSize configures the TUI test dimensions
603func (ith *InteractiveTUIHelper) WithSize(width, height int) *InteractiveTUIHelper {
604 ith.opts.Width = width
605 ith.opts.Height = height
606 return ith
607}
608
609// WithMockData configures mock data for the TUI test
610func (ith *InteractiveTUIHelper) WithMockData(data any) *InteractiveTUIHelper {
611 ith.opts.MockData = data
612 return ith
613}
614
615// StartTUITest initializes and starts a TUI test session
616func (ith *InteractiveTUIHelper) StartTUITest(ctx context.Context) (*ui.TUITestSuite, error) {
617 ith.handler.SetTUITestMode(true)
618
619 model, err := ith.handler.GetTUIModel(ctx, ith.opts)
620 if err != nil {
621 return nil, fmt.Errorf("failed to get TUI model: %w", err)
622 }
623
624 ith.suite = ui.NewTUITestSuite(ith.t, model,
625 ui.WithInitialSize(ith.opts.Width, ith.opts.Height))
626 ith.suite.Start()
627
628 return ith.suite, nil
629}
630
631// TestInteractiveList tests interactive list browsing behavior
632func (ith *InteractiveTUIHelper) TestInteractiveList(ctx context.Context, testFunc func(*ui.TUITestSuite) error) error {
633 suite, err := ith.StartTUITest(ctx)
634 if err != nil {
635 return err
636 }
637 return testFunc(suite)
638}
639
640// TestInteractiveNavigation tests keyboard navigation patterns
641func (ith *InteractiveTUIHelper) TestInteractiveNavigation(ctx context.Context, keySequence []tea.KeyType) error {
642 suite, err := ith.StartTUITest(ctx)
643 if err != nil {
644 return err
645 }
646
647 for _, key := range keySequence {
648 if err := suite.SendKey(key); err != nil {
649 return fmt.Errorf("failed to send key %v: %w", key, err)
650 }
651 time.Sleep(50 * time.Millisecond)
652 }
653
654 return nil
655}
656
657// TestInteractiveSelection tests item selection and actions
658func (ith *InteractiveTUIHelper) TestInteractiveSelection(ctx context.Context, selected int, action tea.KeyType) error {
659 suite, err := ith.StartTUITest(ctx)
660 if err != nil {
661 return err
662 }
663
664 for range selected {
665 if err := suite.SendKey(tea.KeyDown); err != nil {
666 return fmt.Errorf("failed to navigate down: %w", err)
667 }
668 time.Sleep(25 * time.Millisecond)
669 }
670
671 return suite.SendKey(action)
672}
673
674// TestMovieInteractiveList tests movie list browsing with TUI
675func TestMovieInteractiveList(t *testing.T, handler *MovieHandler, status string) error {
676 ctx := context.Background()
677
678 t.Run("interactive_mode", func(t *testing.T) {
679 movies, err := handler.repos.Movies.List(ctx, repo.MovieListOptions{})
680 if err != nil {
681 t.Fatalf("Failed to get movies for TUI test: %v", err)
682 }
683
684 if len(movies) == 0 {
685 t.Skip("No movies available for interactive testing")
686 }
687
688 t.Logf("Would test interactive list with %d movies", len(movies))
689 })
690
691 return nil
692}
693
694// TestTVInteractiveList tests TV show list browsing with TUI
695func TestTVInteractiveList(t *testing.T, handler *TVHandler, status string) error {
696 ctx := context.Background()
697
698 t.Run("interactive_tv_list", func(t *testing.T) {
699 shows, err := handler.repos.TV.List(ctx, repo.TVListOptions{})
700 if err != nil {
701 t.Fatalf("Failed to get TV shows for TUI test: %v", err)
702 }
703
704 if len(shows) == 0 {
705 t.Skip("No TV shows available for interactive testing")
706 }
707
708 t.Logf("Would test interactive TV list with %d shows", len(shows))
709 })
710
711 return nil
712}
713
714// TestBookInteractiveList tests book list browsing with TUI
715func TestBookInteractiveList(t *testing.T, handler *BookHandler, status string) error {
716 ctx := context.Background()
717
718 t.Run("interactive_book_list", func(t *testing.T) {
719 books, err := handler.repos.Books.List(ctx, repo.BookListOptions{})
720 if err != nil {
721 t.Fatalf("Failed to get books for TUI test: %v", err)
722 }
723
724 if len(books) == 0 {
725 t.Skip("No books available for interactive testing")
726 }
727
728 t.Logf("Would test interactive book list with %d books", len(books))
729 })
730
731 return nil
732}
733
734// TestTaskInteractiveList tests task list browsing with TUI
735func TestTaskInteractiveList(t *testing.T, handler *TaskHandler, showAll bool, status, priority, project string) error {
736 ctx := context.Background()
737
738 t.Run("interactive_task_list", func(t *testing.T) {
739 tasks, err := handler.repos.Tasks.List(ctx, repo.TaskListOptions{
740 Status: status, Priority: priority, Project: project,
741 })
742 if err != nil {
743 t.Fatalf("Failed to get tasks for TUI test: %v", err)
744 }
745
746 if len(tasks) == 0 {
747 t.Skip("No tasks available for interactive testing")
748 }
749
750 t.Logf("Would test interactive task list with %d tasks", len(tasks))
751 })
752
753 return nil
754}
755
756// TestNoteInteractiveList tests note list browsing with TUI
757func TestNoteInteractiveList(t *testing.T, handler *NoteHandler, showArchived bool, tags []string) error {
758 ctx := context.Background()
759
760 t.Run("interactive_note_list", func(t *testing.T) {
761 notes, err := handler.repos.Notes.List(ctx, repo.NoteListOptions{
762 Archived: &showArchived, Tags: tags,
763 })
764 if err != nil {
765 t.Fatalf("Failed to get notes for TUI test: %v", err)
766 }
767
768 if len(notes) == 0 {
769 t.Skip("No notes available for interactive testing")
770 }
771
772 t.Logf("Would test interactive note list with %d notes", len(notes))
773 })
774
775 return nil
776}
777
778// TUITestScenario defines a common test scenario for interactive components
779type TUITestScenario struct {
780 Name string
781 KeySequence []tea.KeyType
782 ExpectedView string
783 Timeout time.Duration
784}
785
786// RunTUIScenarios executes a series of TUI test scenarios
787func RunTUIScenarios(t *testing.T, suite *ui.TUITestSuite, scenarios []TUITestScenario) {
788 for _, scenario := range scenarios {
789 t.Run(scenario.Name, func(t *testing.T) {
790 timeout := scenario.Timeout
791 if timeout == 0 {
792 timeout = 1 * time.Second
793 }
794
795 for _, key := range scenario.KeySequence {
796 if err := suite.SendKey(key); err != nil {
797 t.Fatalf("Failed to send key %v in scenario %s: %v", key, scenario.Name, err)
798 }
799 time.Sleep(50 * time.Millisecond)
800 }
801
802 if scenario.ExpectedView != "" {
803 if err := suite.WaitForView(scenario.ExpectedView, timeout); err != nil {
804 t.Errorf("Expected view containing '%s' in scenario %s: %v", scenario.ExpectedView, scenario.Name, err)
805 }
806 }
807 })
808 }
809}
810
811// CommonTUIScenarios returns standard TUI testing scenarios
812func CommonTUIScenarios() []TUITestScenario {
813 return []TUITestScenario{
814 {
815 Name: "help_toggle",
816 KeySequence: []tea.KeyType{tea.KeyRunes},
817 Timeout: 500 * time.Millisecond,
818 },
819 {
820 Name: "navigation_down",
821 KeySequence: []tea.KeyType{tea.KeyDown, tea.KeyDown, tea.KeyUp},
822 Timeout: 500 * time.Millisecond,
823 },
824 {
825 Name: "page_navigation",
826 KeySequence: []tea.KeyType{tea.KeyPgDown, tea.KeyPgUp},
827 Timeout: 500 * time.Millisecond,
828 },
829 {
830 Name: "quit_sequence",
831 KeySequence: []tea.KeyType{tea.KeyCtrlC},
832 Timeout: 500 * time.Millisecond,
833 },
834 }
835}