feat(tui): add 16 race condition reliability tests (bd-3fjk)

- 4 stale response tests: issue list, dashboard, MR list, cross-screen isolation
- 4 SQLITE_BUSY error handling tests: toast display, nav preservation, idempotent toasts, error-then-success
- 7 cancel race tests: cancel/resubmit, rapid 5-submit sequence, key isolation, complete removes handle, stale completion no-op, stuck loading prevention, cancel_all
- 1 issue detail stale guard test
- Added active_cancel_token() method to TaskSupervisor for test observability
This commit is contained in:
teernisse
2026-02-19 00:52:58 -05:00
parent 9bcc512639
commit 656db00c04
2 changed files with 676 additions and 0 deletions

View File

@@ -0,0 +1,668 @@
//! 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<IssueListRow> = (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<MrListRow> = (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<Arc<CancelToken>> = Vec::new();
let mut generations: Vec<u64> = 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"
);
}