//! Vertical slice integration tests for TUI Phase 2. //! //! Validates that core screens work together end-to-end with synthetic //! data flows, navigation preserves state, stale results are dropped, //! and input mode is always recoverable. use ftui::render::frame::Frame; use ftui::render::grapheme_pool::GraphemePool; use ftui::{Cmd, Event, KeyCode, KeyEvent, Model, Modifiers}; use lore_tui::app::LoreApp; use lore_tui::clock::FakeClock; use lore_tui::message::{EntityKey, InputMode, Msg, Screen}; use lore_tui::state::dashboard::{DashboardData, EntityCounts, LastSyncInfo}; use lore_tui::state::issue_detail::{IssueDetailData, IssueMetadata}; use lore_tui::state::issue_list::{IssueListPage, IssueListRow}; use lore_tui::state::mr_detail::MrDetailData; use lore_tui::state::mr_list::{MrListPage, MrListRow}; use lore_tui::task_supervisor::TaskKey; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn test_app() -> LoreApp { let mut app = LoreApp::new(); app.clock = Box::new(FakeClock::new(chrono::Utc::now())); app } fn synthetic_dashboard_data() -> DashboardData { DashboardData { counts: EntityCounts { issues_total: 10, issues_open: 5, mrs_total: 8, mrs_open: 3, discussions: 15, notes_total: 50, notes_system_pct: 20, documents: 20, embeddings: 100, }, projects: vec![], recent: vec![], last_sync: Some(LastSyncInfo { status: "succeeded".into(), finished_at: Some(1_700_000_000_000), command: "sync".into(), error: None, }), } } fn synthetic_issue_list_page() -> IssueListPage { IssueListPage { rows: vec![ IssueListRow { project_path: "group/project".into(), iid: 1, title: "First issue".into(), state: "opened".into(), author: "alice".into(), labels: vec!["backend".into()], updated_at: 1_700_000_000_000, }, IssueListRow { project_path: "group/project".into(), iid: 2, title: "Second issue".into(), state: "closed".into(), author: "bob".into(), labels: vec![], updated_at: 1_700_000_010_000, }, ], next_cursor: None, total_count: 2, } } fn synthetic_issue_detail() -> IssueDetailData { IssueDetailData { metadata: IssueMetadata { iid: 1, project_path: "group/project".into(), title: "First issue".into(), description: "Test description".into(), state: "opened".into(), author: "alice".into(), assignees: vec!["bob".into()], labels: vec!["backend".into()], milestone: None, due_date: None, created_at: 1_700_000_000_000, updated_at: 1_700_000_060_000, web_url: "https://gitlab.com/group/project/-/issues/1".into(), discussion_count: 2, }, cross_refs: vec![], } } fn synthetic_mr_list_page() -> MrListPage { MrListPage { rows: vec![MrListRow { project_path: "group/project".into(), iid: 10, title: "Fix auth".into(), state: "opened".into(), author: "alice".into(), labels: vec![], updated_at: 1_700_000_000_000, draft: false, target_branch: "main".into(), }], next_cursor: None, total_count: 1, } } fn synthetic_mr_detail() -> MrDetailData { MrDetailData { metadata: lore_tui::state::mr_detail::MrMetadata { iid: 10, project_path: "group/project".into(), title: "Fix auth".into(), description: "MR description".into(), state: "opened".into(), draft: false, author: "alice".into(), assignees: vec!["bob".into()], reviewers: vec!["carol".into()], labels: vec![], source_branch: "fix-auth".into(), target_branch: "main".into(), merge_status: "mergeable".into(), created_at: 1_700_000_000_000, updated_at: 1_700_000_060_000, merged_at: None, web_url: "https://gitlab.com/group/project/-/merge_requests/10".into(), discussion_count: 1, file_change_count: 2, }, cross_refs: vec![], file_changes: vec![], } } /// Inject dashboard data with matching generation. fn load_dashboard(app: &mut LoreApp) { let generation = app .supervisor .submit(TaskKey::LoadScreen(Screen::Dashboard)) .generation; app.update(Msg::DashboardLoaded { generation, data: Box::new(synthetic_dashboard_data()), }); } /// Navigate to issue list and inject data. fn navigate_and_load_issue_list(app: &mut LoreApp) { app.update(Msg::NavigateTo(Screen::IssueList)); let generation = app .supervisor .submit(TaskKey::LoadScreen(Screen::IssueList)) .generation; app.update(Msg::IssueListLoaded { generation, page: synthetic_issue_list_page(), }); } /// Navigate to issue detail and inject data. fn navigate_and_load_issue_detail(app: &mut LoreApp, key: EntityKey) { let screen = Screen::IssueDetail(key.clone()); app.update(Msg::NavigateTo(screen.clone())); let generation = app .supervisor .submit(TaskKey::LoadScreen(screen)) .generation; app.update(Msg::IssueDetailLoaded { generation, key, data: Box::new(synthetic_issue_detail()), }); } // --------------------------------------------------------------------------- // Nav flow tests // --------------------------------------------------------------------------- /// TDD anchor: Dashboard -> IssueList -> IssueDetail -> Esc -> IssueList, /// verifies cursor position is preserved on back-navigation. #[test] fn test_dashboard_to_issue_detail_roundtrip() { let mut app = test_app(); assert!(app.navigation.is_at(&Screen::Dashboard)); // Navigate to IssueList and load data. navigate_and_load_issue_list(&mut app); assert!(app.navigation.is_at(&Screen::IssueList)); assert_eq!(app.state.issue_list.rows.len(), 2); // Navigate to IssueDetail for issue #1. let issue_key = EntityKey::issue(1, 1); navigate_and_load_issue_detail(&mut app, issue_key); assert!(matches!(app.navigation.current(), Screen::IssueDetail(_))); // Go back — should return to IssueList with data preserved. app.update(Msg::GoBack); assert!(app.navigation.is_at(&Screen::IssueList)); // Data should still be there (state preserved on navigation). assert_eq!(app.state.issue_list.rows.len(), 2); } /// Navigate Dashboard -> IssueList -> MrList -> MrDetail -> Home. #[test] fn test_full_nav_flow_home() { let mut app = test_app(); // Issue list. navigate_and_load_issue_list(&mut app); assert!(app.navigation.is_at(&Screen::IssueList)); // MR list. app.update(Msg::NavigateTo(Screen::MrList)); let generation = app .supervisor .submit(TaskKey::LoadScreen(Screen::MrList)) .generation; app.update(Msg::MrListLoaded { generation, page: synthetic_mr_list_page(), }); assert!(app.navigation.is_at(&Screen::MrList)); // MR detail. let mr_key = EntityKey::mr(1, 10); let mr_screen = Screen::MrDetail(mr_key.clone()); app.update(Msg::NavigateTo(mr_screen.clone())); let generation = app .supervisor .submit(TaskKey::LoadScreen(mr_screen)) .generation; app.update(Msg::MrDetailLoaded { generation, key: mr_key, data: Box::new(synthetic_mr_detail()), }); assert!(matches!(app.navigation.current(), Screen::MrDetail(_))); // Go home. app.update(Msg::GoHome); assert!(app.navigation.is_at(&Screen::Dashboard)); } /// Verify back-navigation preserves issue list data and MR list data. #[test] fn test_state_preserved_on_back_navigation() { let mut app = test_app(); // Load issue list. navigate_and_load_issue_list(&mut app); assert_eq!(app.state.issue_list.rows.len(), 2); // Navigate to MR list. app.update(Msg::NavigateTo(Screen::MrList)); let generation = app .supervisor .submit(TaskKey::LoadScreen(Screen::MrList)) .generation; app.update(Msg::MrListLoaded { generation, page: synthetic_mr_list_page(), }); // Both states should be populated. assert_eq!(app.state.issue_list.rows.len(), 2); assert_eq!(app.state.mr_list.rows.len(), 1); // Go back to issue list — data should still be there. app.update(Msg::GoBack); assert!(app.navigation.is_at(&Screen::IssueList)); assert_eq!(app.state.issue_list.rows.len(), 2); } // --------------------------------------------------------------------------- // Stale result guard // --------------------------------------------------------------------------- /// Rapidly navigate between screens, injecting out-of-order results. /// Stale results should be silently dropped. #[test] fn test_stale_result_guard_rapid_navigation() { let mut app = test_app(); // Navigate to IssueList, capturing generation. app.update(Msg::NavigateTo(Screen::IssueList)); let generation1 = app .supervisor .submit(TaskKey::LoadScreen(Screen::IssueList)) .generation; // Quickly navigate away and back — new generation. app.update(Msg::GoBack); app.update(Msg::NavigateTo(Screen::IssueList)); let generation2 = app .supervisor .submit(TaskKey::LoadScreen(Screen::IssueList)) .generation; // Late arrival of generation1 — should be dropped. app.update(Msg::IssueListLoaded { generation: generation1, page: IssueListPage { rows: vec![IssueListRow { project_path: "g/p".into(), iid: 999, title: "stale".into(), state: "opened".into(), author: "x".into(), labels: vec![], updated_at: 0, }], next_cursor: None, total_count: 1, }, }); assert!( app.state.issue_list.rows.is_empty(), "stale result should be dropped" ); // generation2 should be accepted. app.update(Msg::IssueListLoaded { generation: generation2, page: synthetic_issue_list_page(), }); assert_eq!(app.state.issue_list.rows.len(), 2); assert_eq!(app.state.issue_list.rows[0].title, "First issue"); } // --------------------------------------------------------------------------- // Input mode fuzz (stuck-input check) // --------------------------------------------------------------------------- /// Fuzz 1000 random key sequences and verify: /// 1. No panics /// 2. InputMode is always recoverable via Esc + Ctrl+C /// 3. Final state is consistent #[test] fn test_input_mode_fuzz_no_stuck_state() { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut app = test_app(); // Deterministic pseudo-random key generation. let keys = [ KeyCode::Char('g'), KeyCode::Char('i'), KeyCode::Char('m'), KeyCode::Char('h'), KeyCode::Char('s'), KeyCode::Char('q'), KeyCode::Char('p'), KeyCode::Char('/'), KeyCode::Char('?'), KeyCode::Tab, KeyCode::BackTab, KeyCode::Escape, KeyCode::Enter, KeyCode::Up, KeyCode::Down, KeyCode::Left, KeyCode::Right, KeyCode::Home, KeyCode::End, ]; let modifiers_set = [ Modifiers::NONE, Modifiers::SHIFT, Modifiers::CTRL, Modifiers::NONE, Modifiers::NONE, ]; // Run 1000 random key events. for i in 0..1000_u64 { // Simple deterministic hash to pick key + modifier. let mut hasher = DefaultHasher::new(); i.hash(&mut hasher); let h = hasher.finish(); let key_code = keys[(h as usize) % keys.len()]; let mods = modifiers_set[((h >> 16) as usize) % modifiers_set.len()]; // Skip Ctrl+C (would quit) and 'q' in normal mode (would quit). if key_code == KeyCode::Char('c') && mods.contains(Modifiers::CTRL) { continue; } if key_code == KeyCode::Char('q') && mods == Modifiers::NONE { // Only skip if in Normal mode to avoid quitting the test. if matches!(app.input_mode, InputMode::Normal) { continue; } } let key_event = if mods == Modifiers::NONE { KeyEvent::new(key_code) } else { KeyEvent::new(key_code).with_modifiers(mods) }; let cmd = app.update(Msg::RawEvent(Event::Key(key_event))); // Should never produce Quit from our filtered set (we skip q and Ctrl+C). if matches!(cmd, Cmd::Quit) { // This can happen from 'q' in non-Normal modes where we didn't filter. // Recreate app to continue fuzzing. app = test_app(); } } // Recovery check: Esc should always bring us back to Normal mode. app.update(Msg::RawEvent(Event::Key(KeyEvent::new(KeyCode::Escape)))); // After Esc, we should be in Normal mode (or if already Normal, stay there). // GoPrefix → Normal, Text → Normal, Palette → Normal. assert!( matches!(app.input_mode, InputMode::Normal), "Esc should always recover to Normal mode, got: {:?}", app.input_mode ); // Ctrl+C should always quit. let ctrl_c = KeyEvent::new(KeyCode::Char('c')).with_modifiers(Modifiers::CTRL); let cmd = app.update(Msg::RawEvent(Event::Key(ctrl_c))); assert!(matches!(cmd, Cmd::Quit)); } // --------------------------------------------------------------------------- // Bootstrap → Dashboard transition // --------------------------------------------------------------------------- /// Bootstrap screen should auto-transition to Dashboard when sync completes. #[test] fn test_bootstrap_to_dashboard_after_sync() { let mut app = test_app(); // Start on Bootstrap screen. app.update(Msg::NavigateTo(Screen::Bootstrap)); assert!(app.navigation.is_at(&Screen::Bootstrap)); assert!(!app.state.bootstrap.sync_started); // User starts sync via key path (g then s). app.update(Msg::RawEvent(Event::Key(KeyEvent::new(KeyCode::Char('g'))))); app.update(Msg::RawEvent(Event::Key(KeyEvent::new(KeyCode::Char('s'))))); assert!(app.state.bootstrap.sync_started); // Sync completes — should auto-transition to Dashboard. app.update(Msg::SyncCompleted { elapsed_ms: 5000 }); assert!( app.navigation.is_at(&Screen::Dashboard), "Should auto-transition to Dashboard after sync completes on Bootstrap" ); } /// SyncCompleted on non-Bootstrap screen should NOT navigate. #[test] fn test_sync_completed_does_not_navigate_from_other_screens() { let mut app = test_app(); // Navigate to IssueList. app.update(Msg::NavigateTo(Screen::IssueList)); assert!(app.navigation.is_at(&Screen::IssueList)); // SyncCompleted should be a no-op. app.update(Msg::SyncCompleted { elapsed_ms: 3000 }); assert!( app.navigation.is_at(&Screen::IssueList), "SyncCompleted should not navigate when not on Bootstrap" ); } // --------------------------------------------------------------------------- // Render all screens (no-panic check) // --------------------------------------------------------------------------- /// Render every screen variant to verify no panics with synthetic data. #[test] fn test_render_all_screens_no_panic() { let mut pool = GraphemePool::new(); // Load data for all screens. let mut app = test_app(); load_dashboard(&mut app); navigate_and_load_issue_list(&mut app); app.update(Msg::GoBack); // Load MR list. app.update(Msg::NavigateTo(Screen::MrList)); let generation = app .supervisor .submit(TaskKey::LoadScreen(Screen::MrList)) .generation; app.update(Msg::MrListLoaded { generation, page: synthetic_mr_list_page(), }); app.update(Msg::GoBack); // Render at each screen. let screens = [ Screen::Dashboard, Screen::IssueList, Screen::MrList, Screen::Bootstrap, ]; for screen in &screens { app.update(Msg::NavigateTo(screen.clone())); let mut frame = Frame::new(80, 24, &mut pool); app.view(&mut frame); } // Render detail screens. let issue_key = EntityKey::issue(1, 1); navigate_and_load_issue_detail(&mut app, issue_key); { let mut frame = Frame::new(80, 24, &mut pool); app.view(&mut frame); } app.update(Msg::GoBack); let mr_key = EntityKey::mr(1, 10); let mr_screen = Screen::MrDetail(mr_key.clone()); app.update(Msg::NavigateTo(mr_screen.clone())); let generation = app .supervisor .submit(TaskKey::LoadScreen(mr_screen)) .generation; app.update(Msg::MrDetailLoaded { generation, key: mr_key, data: Box::new(synthetic_mr_detail()), }); { let mut frame = Frame::new(80, 24, &mut pool); app.view(&mut frame); } } /// Render at various terminal sizes to catch layout panics. #[test] fn test_render_various_sizes_no_panic() { let mut pool = GraphemePool::new(); let app = test_app(); let sizes: [(u16, u16); 5] = [ (80, 24), // Standard (120, 40), // Large (40, 12), // Small (20, 5), // Very small (3, 3), // Minimum ]; for (w, h) in &sizes { let mut frame = Frame::new(*w, *h, &mut pool); app.view(&mut frame); } } // --------------------------------------------------------------------------- // Navigation depth stress // --------------------------------------------------------------------------- /// Navigate deep and verify back-navigation works correctly. #[test] fn test_deep_navigation_and_unwind() { let mut app = test_app(); // Navigate through 10 screens. for i in 0..5 { app.update(Msg::NavigateTo(Screen::IssueList)); let issue_key = EntityKey::issue(1, i + 1); app.update(Msg::NavigateTo(Screen::IssueDetail(issue_key))); } // Should be at IssueDetail depth. assert!(matches!(app.navigation.current(), Screen::IssueDetail(_))); // Unwind all the way back to Dashboard. for _ in 0..20 { app.update(Msg::GoBack); if app.navigation.is_at(&Screen::Dashboard) { break; } } assert!( app.navigation.is_at(&Screen::Dashboard), "Should eventually reach Dashboard" ); } // --------------------------------------------------------------------------- // Performance (smoke test — real benchmarks need criterion) // --------------------------------------------------------------------------- /// Verify that 100 update() + view() cycles complete quickly. /// This is a smoke test, not a precise benchmark. #[test] fn test_update_view_cycle_performance_smoke() { let mut pool = GraphemePool::new(); let mut app = test_app(); load_dashboard(&mut app); let start = std::time::Instant::now(); for _ in 0..100 { app.update(Msg::Tick); let mut frame = Frame::new(80, 24, &mut pool); app.view(&mut frame); } let elapsed = start.elapsed(); // 100 cycles should complete in well under 1 second. // On a typical machine this takes < 10ms. assert!( elapsed.as_millis() < 1000, "100 update+view cycles took {}ms — too slow", elapsed.as_millis() ); }