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