//! Race condition and reliability tests (bd-3fjk). //! //! Verifies the TUI handles async race conditions correctly: //! - Stale responses from superseded tasks are silently dropped //! - SQLITE_BUSY errors surface a user-friendly toast //! - Cancel/resubmit sequences don't leave stuck loading states //! - InterruptHandle only cancels its owning task's connection //! - Rapid submit/cancel sequences (5 in quick succession) converge correctly use std::sync::Arc; use chrono::{TimeZone, Utc}; use ftui::Model; use lore_tui::app::LoreApp; use lore_tui::clock::FakeClock; use lore_tui::message::{AppError, EntityKey, Msg, Screen}; use lore_tui::state::dashboard::{DashboardData, EntityCounts, LastSyncInfo, ProjectSyncInfo}; use lore_tui::state::issue_list::{IssueListPage, IssueListRow}; use lore_tui::state::mr_list::{MrListPage, MrListRow}; use lore_tui::task_supervisor::{CancelToken, TaskKey}; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- fn frozen_clock() -> FakeClock { FakeClock::new(Utc.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap()) } fn test_app() -> LoreApp { let mut app = LoreApp::new(); app.clock = Box::new(frozen_clock()); app } fn fixture_dashboard_data() -> DashboardData { DashboardData { counts: EntityCounts { issues_total: 42, issues_open: 15, mrs_total: 28, mrs_open: 7, discussions: 120, notes_total: 350, notes_system_pct: 18, documents: 85, embeddings: 200, }, projects: vec![ProjectSyncInfo { path: "infra/platform".into(), minutes_since_sync: 5, }], recent: vec![], last_sync: Some(LastSyncInfo { status: "succeeded".into(), finished_at: Some(1_736_942_100_000), command: "sync".into(), error: None, }), } } fn fixture_issue_list(count: usize) -> IssueListPage { let rows: Vec = (0..count) .map(|i| IssueListRow { project_path: "infra/platform".into(), iid: (100 + i) as i64, title: format!("Issue {i}"), state: "opened".into(), author: "alice".into(), labels: vec![], updated_at: 1_736_942_000_000, }) .collect(); IssueListPage { total_count: count as u64, next_cursor: None, rows, } } fn fixture_mr_list(count: usize) -> MrListPage { let rows: Vec = (0..count) .map(|i| MrListRow { project_path: "infra/platform".into(), iid: (200 + i) as i64, title: format!("MR {i}"), state: "opened".into(), author: "bob".into(), labels: vec![], updated_at: 1_736_942_000_000, draft: false, target_branch: "main".into(), }) .collect(); MrListPage { total_count: count as u64, next_cursor: None, rows, } } fn load_dashboard(app: &mut LoreApp) { let generation = app .supervisor .submit(TaskKey::LoadScreen(Screen::Dashboard)) .generation; app.update(Msg::DashboardLoaded { generation, data: Box::new(fixture_dashboard_data()), }); } // --------------------------------------------------------------------------- // Stale Response Tests // --------------------------------------------------------------------------- /// Stale response with old generation is silently dropped. /// /// Submit task A (gen N), then task B (gen M > N) with the same key. /// Delivering a result with generation N should be a no-op. #[test] fn test_stale_issue_list_response_dropped() { let mut app = test_app(); load_dashboard(&mut app); // Submit first task — get generation A. let gen_a = app .supervisor .submit(TaskKey::LoadScreen(Screen::IssueList)) .generation; // Submit second task (same key) — get generation B, cancels A. let gen_b = app .supervisor .submit(TaskKey::LoadScreen(Screen::IssueList)) .generation; assert!(gen_b > gen_a, "Generation B should be newer than A"); // Deliver stale result with gen_a — should be silently dropped. app.update(Msg::IssueListLoaded { generation: gen_a, page: fixture_issue_list(5), }); assert_eq!( app.state.issue_list.rows.len(), 0, "Stale result should not populate state" ); // Deliver fresh result with gen_b — should be applied. app.update(Msg::IssueListLoaded { generation: gen_b, page: fixture_issue_list(3), }); assert_eq!( app.state.issue_list.rows.len(), 3, "Current-generation result should be applied" ); } /// Stale dashboard response dropped after navigation triggers re-load. #[test] fn test_stale_dashboard_response_dropped() { let mut app = test_app(); // First load. let gen_old = app .supervisor .submit(TaskKey::LoadScreen(Screen::Dashboard)) .generation; // Simulate re-navigation (new load). let gen_new = app .supervisor .submit(TaskKey::LoadScreen(Screen::Dashboard)) .generation; // Deliver old generation — should not apply. let mut old_data = fixture_dashboard_data(); old_data.counts.issues_total = 999; app.update(Msg::DashboardLoaded { generation: gen_old, data: Box::new(old_data), }); assert_eq!( app.state.dashboard.counts.issues_total, 0, "Stale dashboard data should not be applied" ); // Deliver current generation — should apply. app.update(Msg::DashboardLoaded { generation: gen_new, data: Box::new(fixture_dashboard_data()), }); assert_eq!( app.state.dashboard.counts.issues_total, 42, "Current dashboard data should be applied" ); } /// MR list stale response dropped correctly. #[test] fn test_stale_mr_list_response_dropped() { let mut app = test_app(); load_dashboard(&mut app); let gen_a = app .supervisor .submit(TaskKey::LoadScreen(Screen::MrList)) .generation; let gen_b = app .supervisor .submit(TaskKey::LoadScreen(Screen::MrList)) .generation; // Stale. app.update(Msg::MrListLoaded { generation: gen_a, page: fixture_mr_list(10), }); assert_eq!(app.state.mr_list.rows.len(), 0); // Current. app.update(Msg::MrListLoaded { generation: gen_b, page: fixture_mr_list(2), }); assert_eq!(app.state.mr_list.rows.len(), 2); } /// Stale result for one screen does not interfere with another screen's data. #[test] fn test_stale_response_cross_screen_isolation() { let mut app = test_app(); // Submit tasks for two different screens. let gen_issues = app .supervisor .submit(TaskKey::LoadScreen(Screen::IssueList)) .generation; let gen_mrs = app .supervisor .submit(TaskKey::LoadScreen(Screen::MrList)) .generation; // Deliver issue list results. app.update(Msg::IssueListLoaded { generation: gen_issues, page: fixture_issue_list(5), }); assert_eq!(app.state.issue_list.rows.len(), 5); // MR list should still be empty — different key. assert_eq!(app.state.mr_list.rows.len(), 0); // Deliver MR list results. app.update(Msg::MrListLoaded { generation: gen_mrs, page: fixture_mr_list(3), }); assert_eq!(app.state.mr_list.rows.len(), 3); // Issue list should be unchanged. assert_eq!(app.state.issue_list.rows.len(), 5); } // --------------------------------------------------------------------------- // SQLITE_BUSY Error Handling Tests // --------------------------------------------------------------------------- /// DbBusy error shows user-friendly toast with "busy" in message. #[test] fn test_db_busy_shows_toast() { let mut app = test_app(); load_dashboard(&mut app); app.update(Msg::Error(AppError::DbBusy)); assert!( app.state.error_toast.is_some(), "DbBusy should produce an error toast" ); assert!( app.state.error_toast.as_ref().unwrap().contains("busy"), "Toast should mention 'busy'" ); } /// DbBusy error does not crash or alter navigation state. #[test] fn test_db_busy_preserves_navigation() { let mut app = test_app(); load_dashboard(&mut app); app.update(Msg::NavigateTo(Screen::IssueList)); assert!(app.navigation.is_at(&Screen::IssueList)); // DbBusy should not change screen. app.update(Msg::Error(AppError::DbBusy)); assert!( app.navigation.is_at(&Screen::IssueList), "DbBusy error should not alter navigation" ); } /// Multiple consecutive DbBusy errors don't stack — last message wins. #[test] fn test_db_busy_toast_idempotent() { let mut app = test_app(); load_dashboard(&mut app); app.update(Msg::Error(AppError::DbBusy)); app.update(Msg::Error(AppError::DbBusy)); app.update(Msg::Error(AppError::DbBusy)); // Should have exactly one toast (last error). assert!(app.state.error_toast.is_some()); assert!(app.state.error_toast.as_ref().unwrap().contains("busy")); } /// DbBusy followed by successful load clears the error. #[test] fn test_db_busy_then_success_clears_error() { let mut app = test_app(); load_dashboard(&mut app); app.update(Msg::Error(AppError::DbBusy)); assert!(app.state.error_toast.is_some()); // Successful load comes in. let gen_ok = app .supervisor .submit(TaskKey::LoadScreen(Screen::IssueList)) .generation; app.update(Msg::IssueListLoaded { generation: gen_ok, page: fixture_issue_list(3), }); // Error toast should still be set (it's not auto-cleared by data loads). // The user explicitly dismisses it via key press. // What matters is the data was applied despite the prior error. assert_eq!( app.state.issue_list.rows.len(), 3, "Data load should succeed after DbBusy error" ); } // --------------------------------------------------------------------------- // Cancel Race Tests // --------------------------------------------------------------------------- /// Submit, cancel via token, resubmit: new task proceeds normally. #[test] fn test_cancel_then_resubmit_works() { let mut app = test_app(); // Submit first task and capture its cancel token. let gen1 = app .supervisor .submit(TaskKey::LoadScreen(Screen::IssueList)) .generation; let token1 = app .supervisor .active_cancel_token(&TaskKey::LoadScreen(Screen::IssueList)) .expect("Should have active handle"); assert!(!token1.is_cancelled()); // Resubmit with same key — old token should be cancelled. let gen2 = app .supervisor .submit(TaskKey::LoadScreen(Screen::IssueList)) .generation; assert!( token1.is_cancelled(), "Old token should be cancelled on resubmit" ); assert!(gen2 > gen1); // Deliver result for new task. app.update(Msg::IssueListLoaded { generation: gen2, page: fixture_issue_list(4), }); assert_eq!(app.state.issue_list.rows.len(), 4); } /// Rapid sequence: 5 submit cycles for the same key. /// Only the last generation should be accepted. #[test] fn test_rapid_submit_sequence_only_last_wins() { let mut app = test_app(); let mut tokens: Vec> = Vec::new(); let mut generations: Vec = Vec::new(); // Rapidly submit 5 tasks with the same key. for _ in 0..5 { let g = app .supervisor .submit(TaskKey::LoadScreen(Screen::IssueList)) .generation; let token = app .supervisor .active_cancel_token(&TaskKey::LoadScreen(Screen::IssueList)) .expect("Should have active handle"); generations.push(g); tokens.push(token); } // All tokens except the last should be cancelled. for (i, token) in tokens.iter().enumerate() { if i < 4 { assert!(token.is_cancelled(), "Token {i} should be cancelled"); } else { assert!(!token.is_cancelled(), "Last token should still be active"); } } // Deliver results for each generation — only the last should apply. for (i, g) in generations.iter().enumerate() { let count = (i + 1) * 10; app.update(Msg::IssueListLoaded { generation: *g, page: fixture_issue_list(count), }); } // Only the last (50 rows) should have been applied. assert_eq!( app.state.issue_list.rows.len(), 50, "Only the last generation's data should be applied" ); } /// Cancel token from one key does not affect tasks with different keys. #[test] fn test_cancel_token_key_isolation() { let mut app = test_app(); // Submit tasks for two different keys. app.supervisor .submit(TaskKey::LoadScreen(Screen::IssueList)); let issue_token = app .supervisor .active_cancel_token(&TaskKey::LoadScreen(Screen::IssueList)) .expect("issue handle"); app.supervisor.submit(TaskKey::LoadScreen(Screen::MrList)); let mr_token = app .supervisor .active_cancel_token(&TaskKey::LoadScreen(Screen::MrList)) .expect("mr handle"); // Resubmit only the issue task — should cancel issue token but not MR. app.supervisor .submit(TaskKey::LoadScreen(Screen::IssueList)); assert!( issue_token.is_cancelled(), "Issue token should be cancelled" ); assert!(!mr_token.is_cancelled(), "MR token should NOT be cancelled"); } /// After completing a task, the handle is removed and is_current returns false. #[test] fn test_complete_removes_handle() { let mut app = test_app(); let gen_c = app .supervisor .submit(TaskKey::LoadScreen(Screen::IssueList)) .generation; assert!( app.supervisor .is_current(&TaskKey::LoadScreen(Screen::IssueList), gen_c) ); // Complete the task. app.supervisor .complete(&TaskKey::LoadScreen(Screen::IssueList), gen_c); assert!( !app.supervisor .is_current(&TaskKey::LoadScreen(Screen::IssueList), gen_c), "Handle should be removed after completion" ); assert_eq!(app.supervisor.active_count(), 0); } /// Completing with a stale generation does not remove the newer handle. #[test] fn test_complete_stale_does_not_remove_newer() { let mut app = test_app(); let gen1 = app .supervisor .submit(TaskKey::LoadScreen(Screen::IssueList)) .generation; let gen2 = app .supervisor .submit(TaskKey::LoadScreen(Screen::IssueList)) .generation; // Completing with old generation should be a no-op. app.supervisor .complete(&TaskKey::LoadScreen(Screen::IssueList), gen1); assert!( app.supervisor .is_current(&TaskKey::LoadScreen(Screen::IssueList), gen2), "Newer handle should survive stale completion" ); } /// No stuck loading state after cancel-then-resubmit through the full app. #[test] fn test_no_stuck_loading_after_cancel_resubmit() { let mut app = test_app(); load_dashboard(&mut app); // Navigate to issue list — sets LoadingInitial. app.update(Msg::NavigateTo(Screen::IssueList)); assert!(app.navigation.is_at(&Screen::IssueList)); // Re-navigate (resubmit) — cancels old, creates new. app.update(Msg::NavigateTo(Screen::IssueList)); // Deliver the result for the current generation. let gen_cur = app .supervisor .submit(TaskKey::LoadScreen(Screen::IssueList)) .generation; app.update(Msg::IssueListLoaded { generation: gen_cur, page: fixture_issue_list(3), }); // Data should be applied and loading should be idle. assert_eq!(app.state.issue_list.rows.len(), 3); } /// cancel_all cancels all active tasks. #[test] fn test_cancel_all_cancels_everything() { let mut app = test_app(); app.supervisor .submit(TaskKey::LoadScreen(Screen::IssueList)); let t1 = app .supervisor .active_cancel_token(&TaskKey::LoadScreen(Screen::IssueList)) .expect("handle"); app.supervisor.submit(TaskKey::LoadScreen(Screen::MrList)); let t2 = app .supervisor .active_cancel_token(&TaskKey::LoadScreen(Screen::MrList)) .expect("handle"); app.supervisor.submit(TaskKey::SyncStream); let t3 = app .supervisor .active_cancel_token(&TaskKey::SyncStream) .expect("handle"); app.supervisor.cancel_all(); assert!(t1.is_cancelled()); assert!(t2.is_cancelled()); assert!(t3.is_cancelled()); assert_eq!(app.supervisor.active_count(), 0); } // --------------------------------------------------------------------------- // Issue Detail Stale Guard (entity-keyed screens) // --------------------------------------------------------------------------- /// Stale issue detail response is dropped when a newer load supersedes it. #[test] fn test_stale_issue_detail_response_dropped() { let mut app = test_app(); load_dashboard(&mut app); let key = EntityKey::issue(1, 101); let screen = Screen::IssueDetail(key.clone()); let gen_old = app .supervisor .submit(TaskKey::LoadScreen(screen.clone())) .generation; let gen_new = app .supervisor .submit(TaskKey::LoadScreen(screen)) .generation; // Deliver stale response. app.update(Msg::IssueDetailLoaded { generation: gen_old, key: key.clone(), data: Box::new(lore_tui::state::issue_detail::IssueDetailData { metadata: lore_tui::state::issue_detail::IssueMetadata { iid: 101, project_path: "infra/platform".into(), title: "STALE TITLE".into(), description: String::new(), state: "opened".into(), author: "alice".into(), assignees: vec![], labels: vec![], milestone: None, due_date: None, created_at: 0, updated_at: 0, web_url: String::new(), discussion_count: 0, }, cross_refs: vec![], }), }); // Stale — metadata should NOT be populated with "STALE TITLE". assert_ne!( app.state .issue_detail .metadata .as_ref() .map(|m| m.title.as_str()), Some("STALE TITLE"), "Stale issue detail should be dropped" ); // Deliver current response. app.update(Msg::IssueDetailLoaded { generation: gen_new, key, data: Box::new(lore_tui::state::issue_detail::IssueDetailData { metadata: lore_tui::state::issue_detail::IssueMetadata { iid: 101, project_path: "infra/platform".into(), title: "CURRENT TITLE".into(), description: String::new(), state: "opened".into(), author: "alice".into(), assignees: vec![], labels: vec![], milestone: None, due_date: None, created_at: 0, updated_at: 0, web_url: String::new(), discussion_count: 0, }, cross_refs: vec![], }), }); assert_eq!( app.state .issue_detail .metadata .as_ref() .map(|m| m.title.as_str()), Some("CURRENT TITLE"), "Current generation detail should be applied" ); }